mirror of
https://github.com/emilk/egui.git
synced 2026-08-30 13:20:05 -04:00
* Closes https://github.com/emilk/egui/issues/3936 * Closes https://github.com/emilk/egui/issues/3923 * Closes https://github.com/emilk/egui/pull/4058 The interaction code is now done at the start of the frame, using stored `WidgetRect`s from the previous frame. The intention is that the new interaction code should be more accurate, making it easier to hit widgets, and better respecting the rules of overlapping widgets. There is a new `style::Interaction::interact_radius` controlling how far away from a widget the cursor can be and still hit it. This helps big fat fingers hit small widgets on touch screens. This PR adds a new `Context::read_response` which lets you read the `Response` of a `Widget` _before_ you create the widget. This can be used for styling, or for reading the result of an interaction early (to prevent frame-delay) for a widget you add late (so it is on top of other widgets). # ⚠️ BREAKING CHANGES `Memory::dragged_id`, `Memory::set_dragged_id` etc have been moved to `Context`. The semantics for `Context::dragged_id` is slightly different: a widget is not considered dragged until egui it is sure this is not a click-in-progress. For a widget that is only sensitive to drags, that is right away, but for widgets sensitive to both clicks and drags it is not until the mouse has moved a certain distance. # TODO * [x] Fix panel resizing * [x] Fix scroll hover weirdness * [x] Fix Resize widget * [x] Fix drag-and-drop * [x] Test all of egui_demo_app * [x] Change `is_dragging` API * [x] Consistent naming of start/stop or begin/end drag * [x] Test `egui_tiles` * [x] Test Rerun * [x] Document * [x] Document breaking changes in PR description * [x] Test one final time # Saving for a later PR * [ ] Fix https://github.com/emilk/egui/issues/4047 * [ ] Specify what the response order for e.g. `ui.horizontal` is I think both these can be fixed if each `Ui` registers themselves as a `WidgetRect`, with the possibility to interact with it later, as if the interaction was under all widgets on top of it.
1740 lines
58 KiB
Rust
1740 lines
58 KiB
Rust
//! Simple plotting library for [`egui`](https://github.com/emilk/egui).
|
|
//!
|
|
//! Check out [`Plot`] for how to get started.
|
|
//!
|
|
//! ## Feature flags
|
|
#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
|
|
//!
|
|
|
|
mod axis;
|
|
mod items;
|
|
mod legend;
|
|
mod memory;
|
|
mod plot_ui;
|
|
mod transform;
|
|
|
|
use std::{ops::RangeInclusive, sync::Arc};
|
|
|
|
use egui::ahash::HashMap;
|
|
use egui::*;
|
|
use epaint::{util::FloatOrd, Hsva};
|
|
|
|
pub use crate::{
|
|
axis::{Axis, AxisHints, HPlacement, Placement, VPlacement},
|
|
items::{
|
|
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, HLine, Line, LineStyle, MarkerShape,
|
|
Orientation, PlotImage, PlotItem, PlotPoint, PlotPoints, Points, Polygon, Text, VLine,
|
|
},
|
|
legend::{Corner, Legend},
|
|
memory::PlotMemory,
|
|
plot_ui::PlotUi,
|
|
transform::{PlotBounds, PlotTransform},
|
|
};
|
|
|
|
use axis::AxisWidget;
|
|
use items::{horizontal_line, rulers_color, vertical_line};
|
|
use legend::LegendWidget;
|
|
|
|
type LabelFormatterFn = dyn Fn(&str, &PlotPoint) -> String;
|
|
pub type LabelFormatter = Option<Box<LabelFormatterFn>>;
|
|
|
|
type GridSpacerFn = dyn Fn(GridInput) -> Vec<GridMark>;
|
|
type GridSpacer = Box<GridSpacerFn>;
|
|
|
|
type CoordinatesFormatterFn = dyn Fn(&PlotPoint, &PlotBounds) -> String;
|
|
|
|
/// Specifies the coordinates formatting when passed to [`Plot::coordinates_formatter`].
|
|
pub struct CoordinatesFormatter {
|
|
function: Box<CoordinatesFormatterFn>,
|
|
}
|
|
|
|
impl CoordinatesFormatter {
|
|
/// Create a new formatter based on the pointer coordinate and the plot bounds.
|
|
pub fn new(function: impl Fn(&PlotPoint, &PlotBounds) -> String + 'static) -> Self {
|
|
Self {
|
|
function: Box::new(function),
|
|
}
|
|
}
|
|
|
|
/// Show a fixed number of decimal places.
|
|
pub fn with_decimals(num_decimals: usize) -> Self {
|
|
Self {
|
|
function: Box::new(move |value, _| {
|
|
format!("x: {:.d$}\ny: {:.d$}", value.x, value.y, d = num_decimals)
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn format(&self, value: &PlotPoint, bounds: &PlotBounds) -> String {
|
|
(self.function)(value, bounds)
|
|
}
|
|
}
|
|
|
|
impl Default for CoordinatesFormatter {
|
|
fn default() -> Self {
|
|
Self::with_decimals(3)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/// Indicates a vertical or horizontal cursor line in plot coordinates.
|
|
#[derive(Copy, Clone, PartialEq)]
|
|
pub enum Cursor {
|
|
Horizontal { y: f64 },
|
|
Vertical { x: f64 },
|
|
}
|
|
|
|
/// Contains the cursors drawn for a plot widget in a single frame.
|
|
#[derive(PartialEq, Clone)]
|
|
struct PlotFrameCursors {
|
|
id: Id,
|
|
cursors: Vec<Cursor>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct CursorLinkGroups(HashMap<Id, Vec<PlotFrameCursors>>);
|
|
|
|
#[derive(Clone)]
|
|
struct LinkedBounds {
|
|
bounds: PlotBounds,
|
|
auto_bounds: Vec2b,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct BoundsLinkGroups(HashMap<Id, LinkedBounds>);
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/// What [`Plot::show`] returns.
|
|
pub struct PlotResponse<R> {
|
|
/// What the user closure returned.
|
|
pub inner: R,
|
|
|
|
/// The response of the plot.
|
|
pub response: Response,
|
|
|
|
/// The transform between screen coordinates and plot coordinates.
|
|
pub transform: PlotTransform,
|
|
|
|
/// The id of a currently hovered item if any.
|
|
///
|
|
/// This is `None` if either no item was hovered, or the hovered item didn't provide an id.
|
|
pub hovered_plot_item: Option<Id>,
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/// A 2D plot, e.g. a graph of a function.
|
|
///
|
|
/// [`Plot`] supports multiple lines and points.
|
|
///
|
|
/// ```
|
|
/// # egui::__run_test_ui(|ui| {
|
|
/// use egui_plot::{Line, Plot, PlotPoints};
|
|
///
|
|
/// let sin: PlotPoints = (0..1000).map(|i| {
|
|
/// let x = i as f64 * 0.01;
|
|
/// [x, x.sin()]
|
|
/// }).collect();
|
|
/// let line = Line::new(sin);
|
|
/// Plot::new("my_plot").view_aspect(2.0).show(ui, |plot_ui| plot_ui.line(line));
|
|
/// # });
|
|
/// ```
|
|
pub struct Plot {
|
|
id_source: Id,
|
|
id: Option<Id>,
|
|
|
|
center_axis: Vec2b,
|
|
allow_zoom: Vec2b,
|
|
allow_drag: Vec2b,
|
|
allow_scroll: Vec2b,
|
|
allow_double_click_reset: bool,
|
|
allow_boxed_zoom: bool,
|
|
default_auto_bounds: Vec2b,
|
|
min_auto_bounds: PlotBounds,
|
|
margin_fraction: Vec2,
|
|
boxed_zoom_pointer_button: PointerButton,
|
|
linked_axes: Option<(Id, Vec2b)>,
|
|
linked_cursors: Option<(Id, Vec2b)>,
|
|
|
|
min_size: Vec2,
|
|
width: Option<f32>,
|
|
height: Option<f32>,
|
|
data_aspect: Option<f32>,
|
|
view_aspect: Option<f32>,
|
|
|
|
reset: bool,
|
|
|
|
show_x: bool,
|
|
show_y: bool,
|
|
label_formatter: LabelFormatter,
|
|
coordinates_formatter: Option<(Corner, CoordinatesFormatter)>,
|
|
x_axes: Vec<AxisHints>, // default x axes
|
|
y_axes: Vec<AxisHints>, // default y axes
|
|
legend_config: Option<Legend>,
|
|
show_background: bool,
|
|
show_axes: Vec2b,
|
|
|
|
show_grid: Vec2b,
|
|
grid_spacing: Rangef,
|
|
grid_spacers: [GridSpacer; 2],
|
|
sharp_grid_lines: bool,
|
|
clamp_grid: bool,
|
|
|
|
sense: Sense,
|
|
}
|
|
|
|
impl Plot {
|
|
/// Give a unique id for each plot within the same [`Ui`].
|
|
pub fn new(id_source: impl std::hash::Hash) -> Self {
|
|
Self {
|
|
id_source: Id::new(id_source),
|
|
id: None,
|
|
|
|
center_axis: false.into(),
|
|
allow_zoom: true.into(),
|
|
allow_drag: true.into(),
|
|
allow_scroll: true.into(),
|
|
allow_double_click_reset: true,
|
|
allow_boxed_zoom: true,
|
|
default_auto_bounds: true.into(),
|
|
min_auto_bounds: PlotBounds::NOTHING,
|
|
margin_fraction: Vec2::splat(0.05),
|
|
boxed_zoom_pointer_button: PointerButton::Secondary,
|
|
linked_axes: None,
|
|
linked_cursors: None,
|
|
|
|
min_size: Vec2::splat(64.0),
|
|
width: None,
|
|
height: None,
|
|
data_aspect: None,
|
|
view_aspect: None,
|
|
|
|
reset: false,
|
|
|
|
show_x: true,
|
|
show_y: true,
|
|
label_formatter: None,
|
|
coordinates_formatter: None,
|
|
x_axes: vec![AxisHints::new(Axis::X)],
|
|
y_axes: vec![AxisHints::new(Axis::Y)],
|
|
legend_config: None,
|
|
show_background: true,
|
|
show_axes: true.into(),
|
|
|
|
show_grid: true.into(),
|
|
grid_spacing: Rangef::new(8.0, 300.0),
|
|
grid_spacers: [log_grid_spacer(10), log_grid_spacer(10)],
|
|
sharp_grid_lines: true,
|
|
clamp_grid: false,
|
|
|
|
sense: egui::Sense::click_and_drag(),
|
|
}
|
|
}
|
|
|
|
/// Set an explicit (global) id for the plot.
|
|
///
|
|
/// This will override the id set by [`Self::new`].
|
|
///
|
|
/// This is the same `Id` that can be used for [`PlotMemory::load`].
|
|
#[inline]
|
|
pub fn id(mut self, id: Id) -> Self {
|
|
self.id = Some(id);
|
|
self
|
|
}
|
|
|
|
/// width / height ratio of the data.
|
|
/// For instance, it can be useful to set this to `1.0` for when the two axes show the same
|
|
/// unit.
|
|
/// By default the plot window's aspect ratio is used.
|
|
#[inline]
|
|
pub fn data_aspect(mut self, data_aspect: f32) -> Self {
|
|
self.data_aspect = Some(data_aspect);
|
|
self
|
|
}
|
|
|
|
/// width / height ratio of the plot region.
|
|
/// By default no fixed aspect ratio is set (and width/height will fill the ui it is in).
|
|
#[inline]
|
|
pub fn view_aspect(mut self, view_aspect: f32) -> Self {
|
|
self.view_aspect = Some(view_aspect);
|
|
self
|
|
}
|
|
|
|
/// Width of plot. By default a plot will fill the ui it is in.
|
|
/// If you set [`Self::view_aspect`], the width can be calculated from the height.
|
|
#[inline]
|
|
pub fn width(mut self, width: f32) -> Self {
|
|
self.min_size.x = width;
|
|
self.width = Some(width);
|
|
self
|
|
}
|
|
|
|
/// Height of plot. By default a plot will fill the ui it is in.
|
|
/// If you set [`Self::view_aspect`], the height can be calculated from the width.
|
|
#[inline]
|
|
pub fn height(mut self, height: f32) -> Self {
|
|
self.min_size.y = height;
|
|
self.height = Some(height);
|
|
self
|
|
}
|
|
|
|
/// Minimum size of the plot view.
|
|
#[inline]
|
|
pub fn min_size(mut self, min_size: Vec2) -> Self {
|
|
self.min_size = min_size;
|
|
self
|
|
}
|
|
|
|
/// Show the x-value (e.g. when hovering). Default: `true`.
|
|
#[inline]
|
|
pub fn show_x(mut self, show_x: bool) -> Self {
|
|
self.show_x = show_x;
|
|
self
|
|
}
|
|
|
|
/// Show the y-value (e.g. when hovering). Default: `true`.
|
|
#[inline]
|
|
pub fn show_y(mut self, show_y: bool) -> Self {
|
|
self.show_y = show_y;
|
|
self
|
|
}
|
|
|
|
/// Always keep the X-axis centered. Default: `false`.
|
|
#[inline]
|
|
pub fn center_x_axis(mut self, on: bool) -> Self {
|
|
self.center_axis.x = on;
|
|
self
|
|
}
|
|
|
|
/// Always keep the Y-axis centered. Default: `false`.
|
|
#[inline]
|
|
pub fn center_y_axis(mut self, on: bool) -> Self {
|
|
self.center_axis.y = on;
|
|
self
|
|
}
|
|
|
|
/// Whether to allow zooming in the plot. Default: `true`.
|
|
///
|
|
/// Note: Allowing zoom in one axis but not the other may lead to unexpected results if used in combination with `data_aspect`.
|
|
#[inline]
|
|
pub fn allow_zoom<T>(mut self, on: T) -> Self
|
|
where
|
|
T: Into<Vec2b>,
|
|
{
|
|
self.allow_zoom = on.into();
|
|
self
|
|
}
|
|
|
|
/// Whether to allow scrolling in the plot. Default: `true`.
|
|
#[inline]
|
|
pub fn allow_scroll<T>(mut self, on: T) -> Self
|
|
where
|
|
T: Into<Vec2b>,
|
|
{
|
|
self.allow_scroll = on.into();
|
|
self
|
|
}
|
|
|
|
/// Whether to allow double clicking to reset the view.
|
|
/// Default: `true`.
|
|
#[inline]
|
|
pub fn allow_double_click_reset(mut self, on: bool) -> Self {
|
|
self.allow_double_click_reset = on;
|
|
self
|
|
}
|
|
|
|
/// Set the side margin as a fraction of the plot size. Only used for auto bounds.
|
|
///
|
|
/// For instance, a value of `0.1` will add 10% space on both sides.
|
|
#[inline]
|
|
pub fn set_margin_fraction(mut self, margin_fraction: Vec2) -> Self {
|
|
self.margin_fraction = margin_fraction;
|
|
self
|
|
}
|
|
|
|
/// Whether to allow zooming in the plot by dragging out a box with the secondary mouse button.
|
|
///
|
|
/// Default: `true`.
|
|
#[inline]
|
|
pub fn allow_boxed_zoom(mut self, on: bool) -> Self {
|
|
self.allow_boxed_zoom = on;
|
|
self
|
|
}
|
|
|
|
/// Config the button pointer to use for boxed zooming. Default: [`Secondary`](PointerButton::Secondary)
|
|
#[inline]
|
|
pub fn boxed_zoom_pointer_button(mut self, boxed_zoom_pointer_button: PointerButton) -> Self {
|
|
self.boxed_zoom_pointer_button = boxed_zoom_pointer_button;
|
|
self
|
|
}
|
|
|
|
/// Whether to allow dragging in the plot to move the bounds. Default: `true`.
|
|
#[inline]
|
|
pub fn allow_drag<T>(mut self, on: T) -> Self
|
|
where
|
|
T: Into<Vec2b>,
|
|
{
|
|
self.allow_drag = on.into();
|
|
self
|
|
}
|
|
|
|
/// Provide a function to customize the on-hover label for the x and y axis
|
|
///
|
|
/// ```
|
|
/// # egui::__run_test_ui(|ui| {
|
|
/// use egui_plot::{Line, Plot, PlotPoints};
|
|
/// let sin: PlotPoints = (0..1000).map(|i| {
|
|
/// let x = i as f64 * 0.01;
|
|
/// [x, x.sin()]
|
|
/// }).collect();
|
|
/// let line = Line::new(sin);
|
|
/// Plot::new("my_plot").view_aspect(2.0)
|
|
/// .label_formatter(|name, value| {
|
|
/// if !name.is_empty() {
|
|
/// format!("{}: {:.*}%", name, 1, value.y)
|
|
/// } else {
|
|
/// "".to_owned()
|
|
/// }
|
|
/// })
|
|
/// .show(ui, |plot_ui| plot_ui.line(line));
|
|
/// # });
|
|
/// ```
|
|
pub fn label_formatter(
|
|
mut self,
|
|
label_formatter: impl Fn(&str, &PlotPoint) -> String + 'static,
|
|
) -> Self {
|
|
self.label_formatter = Some(Box::new(label_formatter));
|
|
self
|
|
}
|
|
|
|
/// Show the pointer coordinates in the plot.
|
|
pub fn coordinates_formatter(
|
|
mut self,
|
|
position: Corner,
|
|
formatter: CoordinatesFormatter,
|
|
) -> Self {
|
|
self.coordinates_formatter = Some((position, formatter));
|
|
self
|
|
}
|
|
|
|
/// Configure how the grid in the background is spaced apart along the X axis.
|
|
///
|
|
/// Default is a log-10 grid, i.e. every plot unit is divided into 10 other units.
|
|
///
|
|
/// The function has this signature:
|
|
/// ```ignore
|
|
/// fn step_sizes(input: GridInput) -> Vec<GridMark>;
|
|
/// ```
|
|
///
|
|
/// This function should return all marks along the visible range of the X axis.
|
|
/// `step_size` also determines how thick/faint each line is drawn.
|
|
/// For example, if x = 80..=230 is visible and you want big marks at steps of
|
|
/// 100 and small ones at 25, you can return:
|
|
/// ```no_run
|
|
/// # use egui_plot::GridMark;
|
|
/// vec![
|
|
/// // 100s
|
|
/// GridMark { value: 100.0, step_size: 100.0 },
|
|
/// GridMark { value: 200.0, step_size: 100.0 },
|
|
///
|
|
/// // 25s
|
|
/// GridMark { value: 125.0, step_size: 25.0 },
|
|
/// GridMark { value: 150.0, step_size: 25.0 },
|
|
/// GridMark { value: 175.0, step_size: 25.0 },
|
|
/// GridMark { value: 225.0, step_size: 25.0 },
|
|
/// ];
|
|
/// # ()
|
|
/// ```
|
|
///
|
|
/// There are helpers for common cases, see [`log_grid_spacer`] and [`uniform_grid_spacer`].
|
|
#[inline]
|
|
pub fn x_grid_spacer(mut self, spacer: impl Fn(GridInput) -> Vec<GridMark> + 'static) -> Self {
|
|
self.grid_spacers[0] = Box::new(spacer);
|
|
self
|
|
}
|
|
|
|
/// Default is a log-10 grid, i.e. every plot unit is divided into 10 other units.
|
|
///
|
|
/// See [`Self::x_grid_spacer`] for explanation.
|
|
#[inline]
|
|
pub fn y_grid_spacer(mut self, spacer: impl Fn(GridInput) -> Vec<GridMark> + 'static) -> Self {
|
|
self.grid_spacers[1] = Box::new(spacer);
|
|
self
|
|
}
|
|
|
|
/// Set when the grid starts showing.
|
|
///
|
|
/// When grid lines are closer than the given minimum, they will be hidden.
|
|
/// When they get further apart they will fade in, until the reaches the given maximum,
|
|
/// at which point they are fully opaque.
|
|
#[inline]
|
|
pub fn grid_spacing(mut self, grid_spacing: impl Into<Rangef>) -> Self {
|
|
self.grid_spacing = grid_spacing.into();
|
|
self
|
|
}
|
|
|
|
/// Clamp the grid to only be visible at the range of data where we have values.
|
|
///
|
|
/// Default: `false`.
|
|
#[inline]
|
|
pub fn clamp_grid(mut self, clamp_grid: bool) -> Self {
|
|
self.clamp_grid = clamp_grid;
|
|
self
|
|
}
|
|
|
|
/// Set the sense for the plot rect.
|
|
///
|
|
/// Default: `Sense::click_and_drag()`.
|
|
#[inline]
|
|
pub fn sense(mut self, sense: Sense) -> Self {
|
|
self.sense = sense;
|
|
self
|
|
}
|
|
|
|
/// Expand bounds to include the given x value.
|
|
/// For instance, to always show the y axis, call `plot.include_x(0.0)`.
|
|
#[inline]
|
|
pub fn include_x(mut self, x: impl Into<f64>) -> Self {
|
|
self.min_auto_bounds.extend_with_x(x.into());
|
|
self
|
|
}
|
|
|
|
/// Expand bounds to include the given y value.
|
|
/// For instance, to always show the x axis, call `plot.include_y(0.0)`.
|
|
#[inline]
|
|
pub fn include_y(mut self, y: impl Into<f64>) -> Self {
|
|
self.min_auto_bounds.extend_with_y(y.into());
|
|
self
|
|
}
|
|
|
|
/// Set whether the bounds should be automatically set based on data by default.
|
|
///
|
|
/// This is enabled by default.
|
|
#[inline]
|
|
pub fn auto_bounds(mut self, auto_bounds: Vec2b) -> Self {
|
|
self.default_auto_bounds = auto_bounds;
|
|
self
|
|
}
|
|
|
|
/// Expand bounds to fit all items across the x axis, including values given by `include_x`.
|
|
#[deprecated = "Use `auto_bounds` instead"]
|
|
#[inline]
|
|
pub fn auto_bounds_x(mut self) -> Self {
|
|
self.default_auto_bounds.x = true;
|
|
self
|
|
}
|
|
|
|
/// Expand bounds to fit all items across the y axis, including values given by `include_y`.
|
|
#[deprecated = "Use `auto_bounds` instead"]
|
|
#[inline]
|
|
pub fn auto_bounds_y(mut self) -> Self {
|
|
self.default_auto_bounds.y = true;
|
|
self
|
|
}
|
|
|
|
/// Show a legend including all named items.
|
|
#[inline]
|
|
pub fn legend(mut self, legend: Legend) -> Self {
|
|
self.legend_config = Some(legend);
|
|
self
|
|
}
|
|
|
|
/// Whether or not to show the background [`Rect`].
|
|
///
|
|
/// Can be useful to disable if the plot is overlaid over existing content.
|
|
/// Default: `true`.
|
|
#[inline]
|
|
pub fn show_background(mut self, show: bool) -> Self {
|
|
self.show_background = show;
|
|
self
|
|
}
|
|
|
|
/// Show axis labels and grid tick values on the side of the plot.
|
|
///
|
|
/// Default: `true`.
|
|
#[inline]
|
|
pub fn show_axes(mut self, show: impl Into<Vec2b>) -> Self {
|
|
self.show_axes = show.into();
|
|
self
|
|
}
|
|
|
|
/// Show a grid overlay on the plot.
|
|
///
|
|
/// Default: `true`.
|
|
#[inline]
|
|
pub fn show_grid(mut self, show: impl Into<Vec2b>) -> Self {
|
|
self.show_grid = show.into();
|
|
self
|
|
}
|
|
|
|
/// Add this plot to an axis link group so that this plot will share the bounds with other plots in the
|
|
/// same group. A plot cannot belong to more than one axis group.
|
|
#[inline]
|
|
pub fn link_axis(mut self, group_id: impl Into<Id>, link_x: bool, link_y: bool) -> Self {
|
|
self.linked_axes = Some((
|
|
group_id.into(),
|
|
Vec2b {
|
|
x: link_x,
|
|
y: link_y,
|
|
},
|
|
));
|
|
self
|
|
}
|
|
|
|
/// Add this plot to a cursor link group so that this plot will share the cursor position with other plots
|
|
/// in the same group. A plot cannot belong to more than one cursor group.
|
|
#[inline]
|
|
pub fn link_cursor(mut self, group_id: impl Into<Id>, link_x: bool, link_y: bool) -> Self {
|
|
self.linked_cursors = Some((
|
|
group_id.into(),
|
|
Vec2b {
|
|
x: link_x,
|
|
y: link_y,
|
|
},
|
|
));
|
|
self
|
|
}
|
|
|
|
/// Round grid positions to full pixels to avoid aliasing. Improves plot appearance but might have an
|
|
/// undesired effect when shifting the plot bounds. Enabled by default.
|
|
#[inline]
|
|
pub fn sharp_grid_lines(mut self, enabled: bool) -> Self {
|
|
self.sharp_grid_lines = enabled;
|
|
self
|
|
}
|
|
|
|
/// Resets the plot.
|
|
#[inline]
|
|
pub fn reset(mut self) -> Self {
|
|
self.reset = true;
|
|
self
|
|
}
|
|
|
|
/// Set the x axis label of the main X-axis.
|
|
///
|
|
/// Default: no label.
|
|
#[inline]
|
|
pub fn x_axis_label(mut self, label: impl Into<WidgetText>) -> Self {
|
|
if let Some(main) = self.x_axes.first_mut() {
|
|
main.label = label.into();
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Set the y axis label of the main Y-axis.
|
|
///
|
|
/// Default: no label.
|
|
#[inline]
|
|
pub fn y_axis_label(mut self, label: impl Into<WidgetText>) -> Self {
|
|
if let Some(main) = self.y_axes.first_mut() {
|
|
main.label = label.into();
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Set the position of the main X-axis.
|
|
#[inline]
|
|
pub fn x_axis_position(mut self, placement: axis::VPlacement) -> Self {
|
|
if let Some(main) = self.x_axes.first_mut() {
|
|
main.placement = placement.into();
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Set the position of the main Y-axis.
|
|
#[inline]
|
|
pub fn y_axis_position(mut self, placement: axis::HPlacement) -> Self {
|
|
if let Some(main) = self.y_axes.first_mut() {
|
|
main.placement = placement.into();
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Specify custom formatter for ticks on the main X-axis.
|
|
///
|
|
/// Arguments of `fmt`:
|
|
/// * the grid mark to format
|
|
/// * maximum requested number of characters per tick label.
|
|
/// * currently shown range on this axis.
|
|
pub fn x_axis_formatter(
|
|
mut self,
|
|
fmt: impl Fn(GridMark, usize, &RangeInclusive<f64>) -> String + 'static,
|
|
) -> Self {
|
|
if let Some(main) = self.x_axes.first_mut() {
|
|
main.formatter = Arc::new(fmt);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Specify custom formatter for ticks on the main Y-axis.
|
|
///
|
|
/// Arguments of `fmt`:
|
|
/// * the grid mark to format
|
|
/// * maximum requested number of characters per tick label.
|
|
/// * currently shown range on this axis.
|
|
pub fn y_axis_formatter(
|
|
mut self,
|
|
fmt: impl Fn(GridMark, usize, &RangeInclusive<f64>) -> String + 'static,
|
|
) -> Self {
|
|
if let Some(main) = self.y_axes.first_mut() {
|
|
main.formatter = Arc::new(fmt);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Set the main Y-axis-width by number of digits
|
|
///
|
|
/// The default is 5 digits.
|
|
///
|
|
/// > Todo: This is experimental. Changing the font size might break this.
|
|
#[inline]
|
|
pub fn y_axis_width(mut self, digits: usize) -> Self {
|
|
if let Some(main) = self.y_axes.first_mut() {
|
|
main.digits = digits;
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Set custom configuration for X-axis
|
|
///
|
|
/// More than one axis may be specified. The first specified axis is considered the main axis.
|
|
#[inline]
|
|
pub fn custom_x_axes(mut self, hints: Vec<AxisHints>) -> Self {
|
|
self.x_axes = hints;
|
|
self
|
|
}
|
|
|
|
/// Set custom configuration for left Y-axis
|
|
///
|
|
/// More than one axis may be specified. The first specified axis is considered the main axis.
|
|
#[inline]
|
|
pub fn custom_y_axes(mut self, hints: Vec<AxisHints>) -> Self {
|
|
self.y_axes = hints;
|
|
self
|
|
}
|
|
|
|
/// Interact with and add items to the plot and finally draw it.
|
|
pub fn show<R>(self, ui: &mut Ui, build_fn: impl FnOnce(&mut PlotUi) -> R) -> PlotResponse<R> {
|
|
self.show_dyn(ui, Box::new(build_fn))
|
|
}
|
|
|
|
fn show_dyn<'a, R>(
|
|
self,
|
|
ui: &mut Ui,
|
|
build_fn: Box<dyn FnOnce(&mut PlotUi) -> R + 'a>,
|
|
) -> PlotResponse<R> {
|
|
let Self {
|
|
id_source,
|
|
id,
|
|
center_axis,
|
|
allow_zoom,
|
|
allow_drag,
|
|
allow_scroll,
|
|
allow_double_click_reset,
|
|
allow_boxed_zoom,
|
|
boxed_zoom_pointer_button,
|
|
default_auto_bounds,
|
|
min_auto_bounds,
|
|
margin_fraction,
|
|
width,
|
|
height,
|
|
min_size,
|
|
data_aspect,
|
|
view_aspect,
|
|
mut show_x,
|
|
mut show_y,
|
|
label_formatter,
|
|
coordinates_formatter,
|
|
x_axes,
|
|
y_axes,
|
|
legend_config,
|
|
reset,
|
|
show_background,
|
|
show_axes,
|
|
show_grid,
|
|
grid_spacing,
|
|
linked_axes,
|
|
linked_cursors,
|
|
|
|
clamp_grid,
|
|
grid_spacers,
|
|
sharp_grid_lines,
|
|
sense,
|
|
} = self;
|
|
|
|
// Determine position of widget.
|
|
let pos = ui.available_rect_before_wrap().min;
|
|
// Determine size of widget.
|
|
let size = {
|
|
let width = width
|
|
.unwrap_or_else(|| {
|
|
if let (Some(height), Some(aspect)) = (height, view_aspect) {
|
|
height * aspect
|
|
} else {
|
|
ui.available_size_before_wrap().x
|
|
}
|
|
})
|
|
.at_least(min_size.x);
|
|
|
|
let height = height
|
|
.unwrap_or_else(|| {
|
|
if let Some(aspect) = view_aspect {
|
|
width / aspect
|
|
} else {
|
|
ui.available_size_before_wrap().y
|
|
}
|
|
})
|
|
.at_least(min_size.y);
|
|
vec2(width, height)
|
|
};
|
|
|
|
// Determine complete rect of widget.
|
|
let complete_rect = Rect {
|
|
min: pos,
|
|
max: pos + size,
|
|
};
|
|
|
|
let plot_id = id.unwrap_or_else(|| ui.make_persistent_id(id_source));
|
|
|
|
let ([x_axis_widgets, y_axis_widgets], plot_rect) = axis_widgets(
|
|
PlotMemory::load(ui.ctx(), plot_id).as_ref(), // TODO: avoid loading plot memory twice
|
|
show_axes,
|
|
complete_rect,
|
|
[&x_axes, &y_axes],
|
|
);
|
|
|
|
// Allocate the plot window.
|
|
let response = ui.allocate_rect(plot_rect, sense);
|
|
|
|
// Load or initialize the memory.
|
|
ui.ctx().check_for_id_clash(plot_id, plot_rect, "Plot");
|
|
|
|
let mut mem = if reset {
|
|
if let Some((name, _)) = linked_axes.as_ref() {
|
|
ui.data_mut(|data| {
|
|
let link_groups: &mut BoundsLinkGroups = data.get_temp_mut_or_default(Id::NULL);
|
|
link_groups.0.remove(name);
|
|
});
|
|
};
|
|
None
|
|
} else {
|
|
PlotMemory::load(ui.ctx(), plot_id)
|
|
}
|
|
.unwrap_or_else(|| PlotMemory {
|
|
auto_bounds: default_auto_bounds,
|
|
hovered_legend_item: None,
|
|
hidden_items: Default::default(),
|
|
transform: PlotTransform::new(plot_rect, min_auto_bounds, center_axis.x, center_axis.y),
|
|
last_click_pos_for_zoom: None,
|
|
x_axis_thickness: Default::default(),
|
|
y_axis_thickness: Default::default(),
|
|
});
|
|
|
|
let last_plot_transform = mem.transform;
|
|
|
|
// Call the plot build function.
|
|
let mut plot_ui = PlotUi {
|
|
ctx: ui.ctx().clone(),
|
|
items: Vec::new(),
|
|
next_auto_color_idx: 0,
|
|
last_plot_transform,
|
|
last_auto_bounds: mem.auto_bounds,
|
|
response,
|
|
bounds_modifications: Vec::new(),
|
|
};
|
|
let inner = build_fn(&mut plot_ui);
|
|
let PlotUi {
|
|
mut items,
|
|
mut response,
|
|
last_plot_transform,
|
|
bounds_modifications,
|
|
..
|
|
} = plot_ui;
|
|
|
|
// Background
|
|
if show_background {
|
|
ui.painter()
|
|
.with_clip_rect(plot_rect)
|
|
.add(epaint::RectShape::new(
|
|
plot_rect,
|
|
Rounding::same(2.0),
|
|
ui.visuals().extreme_bg_color,
|
|
ui.visuals().widgets.noninteractive.bg_stroke,
|
|
));
|
|
}
|
|
|
|
// --- Legend ---
|
|
let legend = legend_config
|
|
.and_then(|config| LegendWidget::try_new(plot_rect, config, &items, &mem.hidden_items));
|
|
// Don't show hover cursor when hovering over legend.
|
|
if mem.hovered_legend_item.is_some() {
|
|
show_x = false;
|
|
show_y = false;
|
|
}
|
|
// Remove the deselected items.
|
|
items.retain(|item| !mem.hidden_items.contains(item.name()));
|
|
// Highlight the hovered items.
|
|
if let Some(hovered_name) = &mem.hovered_legend_item {
|
|
items
|
|
.iter_mut()
|
|
.filter(|entry| entry.name() == hovered_name)
|
|
.for_each(|entry| entry.highlight());
|
|
}
|
|
// Move highlighted items to front.
|
|
items.sort_by_key(|item| item.highlighted());
|
|
|
|
// --- Bound computation ---
|
|
let mut bounds = *last_plot_transform.bounds();
|
|
|
|
// Find the cursors from other plots we need to draw
|
|
let draw_cursors: Vec<Cursor> = if let Some((id, _)) = linked_cursors.as_ref() {
|
|
ui.data_mut(|data| {
|
|
let frames: &mut CursorLinkGroups = data.get_temp_mut_or_default(Id::NULL);
|
|
let cursors = frames.0.entry(*id).or_default();
|
|
|
|
// Look for our previous frame
|
|
let index = cursors
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, frame)| frame.id == plot_id)
|
|
.map(|(i, _)| i);
|
|
|
|
// Remove our previous frame and all older frames as these are no longer displayed. This avoids
|
|
// unbounded growth, as we add an entry each time we draw a plot.
|
|
index.map(|index| cursors.drain(0..=index));
|
|
|
|
// Gather all cursors of the remaining frames. This will be all the cursors of the
|
|
// other plots in the group. We want to draw these in the current plot too.
|
|
cursors
|
|
.iter()
|
|
.flat_map(|frame| frame.cursors.iter().copied())
|
|
.collect()
|
|
})
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Transfer the bounds from a link group.
|
|
if let Some((id, axes)) = linked_axes.as_ref() {
|
|
ui.data_mut(|data| {
|
|
let link_groups: &mut BoundsLinkGroups = data.get_temp_mut_or_default(Id::NULL);
|
|
if let Some(linked_bounds) = link_groups.0.get(id) {
|
|
if axes.x {
|
|
bounds.set_x(&linked_bounds.bounds);
|
|
mem.auto_bounds.x = linked_bounds.auto_bounds.x;
|
|
}
|
|
if axes.y {
|
|
bounds.set_y(&linked_bounds.bounds);
|
|
mem.auto_bounds.y = linked_bounds.auto_bounds.y;
|
|
}
|
|
};
|
|
});
|
|
};
|
|
|
|
// Allow double-clicking to reset to the initial bounds.
|
|
if allow_double_click_reset && response.double_clicked() {
|
|
mem.auto_bounds = true.into();
|
|
}
|
|
|
|
// Apply bounds modifications.
|
|
for modification in bounds_modifications {
|
|
match modification {
|
|
BoundsModification::Set(new_bounds) => {
|
|
bounds = new_bounds;
|
|
mem.auto_bounds = false.into();
|
|
}
|
|
BoundsModification::Translate(delta) => {
|
|
bounds.translate(delta);
|
|
mem.auto_bounds = false.into();
|
|
}
|
|
BoundsModification::AutoBounds(new_auto_bounds) => {
|
|
mem.auto_bounds = new_auto_bounds;
|
|
}
|
|
BoundsModification::Zoom(zoom_factor, center) => {
|
|
bounds.zoom(zoom_factor, center);
|
|
mem.auto_bounds = false.into();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset bounds to initial bounds if they haven't been modified.
|
|
if mem.auto_bounds.x {
|
|
bounds.set_x(&min_auto_bounds);
|
|
}
|
|
if mem.auto_bounds.y {
|
|
bounds.set_y(&min_auto_bounds);
|
|
}
|
|
|
|
let auto_x = mem.auto_bounds.x && (!min_auto_bounds.is_valid_x() || default_auto_bounds.x);
|
|
let auto_y = mem.auto_bounds.y && (!min_auto_bounds.is_valid_y() || default_auto_bounds.y);
|
|
|
|
// Set bounds automatically based on content.
|
|
if auto_x || auto_y {
|
|
for item in &items {
|
|
let item_bounds = item.bounds();
|
|
if auto_x {
|
|
bounds.merge_x(&item_bounds);
|
|
}
|
|
if auto_y {
|
|
bounds.merge_y(&item_bounds);
|
|
}
|
|
}
|
|
|
|
if auto_x {
|
|
bounds.add_relative_margin_x(margin_fraction);
|
|
}
|
|
|
|
if auto_y {
|
|
bounds.add_relative_margin_y(margin_fraction);
|
|
}
|
|
}
|
|
|
|
mem.transform = PlotTransform::new(plot_rect, bounds, center_axis.x, center_axis.y);
|
|
|
|
// Enforce aspect ratio
|
|
if let Some(data_aspect) = data_aspect {
|
|
if let Some((_, linked_axes)) = &linked_axes {
|
|
let change_x = linked_axes.y && !linked_axes.x;
|
|
mem.transform.set_aspect_by_changing_axis(
|
|
data_aspect as f64,
|
|
if change_x { Axis::X } else { Axis::Y },
|
|
);
|
|
} else if default_auto_bounds.any() {
|
|
mem.transform.set_aspect_by_expanding(data_aspect as f64);
|
|
} else {
|
|
mem.transform
|
|
.set_aspect_by_changing_axis(data_aspect as f64, Axis::Y);
|
|
}
|
|
}
|
|
|
|
// Dragging
|
|
if allow_drag.any() && response.dragged_by(PointerButton::Primary) {
|
|
response = response.on_hover_cursor(CursorIcon::Grabbing);
|
|
let mut delta = -response.drag_delta();
|
|
if !allow_drag.x {
|
|
delta.x = 0.0;
|
|
}
|
|
if !allow_drag.y {
|
|
delta.y = 0.0;
|
|
}
|
|
mem.transform.translate_bounds(delta);
|
|
mem.auto_bounds = !allow_drag;
|
|
}
|
|
|
|
// Zooming
|
|
let mut boxed_zoom_rect = None;
|
|
if allow_boxed_zoom {
|
|
// Save last click to allow boxed zooming
|
|
if response.drag_started() && response.dragged_by(boxed_zoom_pointer_button) {
|
|
// it would be best for egui that input has a memory of the last click pos because it's a common pattern
|
|
mem.last_click_pos_for_zoom = response.hover_pos();
|
|
}
|
|
let box_start_pos = mem.last_click_pos_for_zoom;
|
|
let box_end_pos = response.hover_pos();
|
|
if let (Some(box_start_pos), Some(box_end_pos)) = (box_start_pos, box_end_pos) {
|
|
// while dragging prepare a Shape and draw it later on top of the plot
|
|
if response.dragged_by(boxed_zoom_pointer_button) {
|
|
response = response.on_hover_cursor(CursorIcon::ZoomIn);
|
|
let rect = epaint::Rect::from_two_pos(box_start_pos, box_end_pos);
|
|
boxed_zoom_rect = Some((
|
|
epaint::RectShape::stroke(
|
|
rect,
|
|
0.0,
|
|
epaint::Stroke::new(4., Color32::DARK_BLUE),
|
|
), // Outer stroke
|
|
epaint::RectShape::stroke(
|
|
rect,
|
|
0.0,
|
|
epaint::Stroke::new(2., Color32::WHITE),
|
|
), // Inner stroke
|
|
));
|
|
}
|
|
// when the click is release perform the zoom
|
|
if response.drag_stopped() {
|
|
let box_start_pos = mem.transform.value_from_position(box_start_pos);
|
|
let box_end_pos = mem.transform.value_from_position(box_end_pos);
|
|
let new_bounds = PlotBounds {
|
|
min: [
|
|
box_start_pos.x.min(box_end_pos.x),
|
|
box_start_pos.y.min(box_end_pos.y),
|
|
],
|
|
max: [
|
|
box_start_pos.x.max(box_end_pos.x),
|
|
box_start_pos.y.max(box_end_pos.y),
|
|
],
|
|
};
|
|
if new_bounds.is_valid() {
|
|
mem.transform.set_bounds(new_bounds);
|
|
mem.auto_bounds = false.into();
|
|
}
|
|
// reset the boxed zoom state
|
|
mem.last_click_pos_for_zoom = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
let hover_pos = response.hover_pos();
|
|
if let Some(hover_pos) = hover_pos {
|
|
if allow_zoom.any() {
|
|
let mut zoom_factor = if data_aspect.is_some() {
|
|
Vec2::splat(ui.input(|i| i.zoom_delta()))
|
|
} else {
|
|
ui.input(|i| i.zoom_delta_2d())
|
|
};
|
|
if !allow_zoom.x {
|
|
zoom_factor.x = 1.0;
|
|
}
|
|
if !allow_zoom.y {
|
|
zoom_factor.y = 1.0;
|
|
}
|
|
if zoom_factor != Vec2::splat(1.0) {
|
|
mem.transform.zoom(zoom_factor, hover_pos);
|
|
mem.auto_bounds = !allow_zoom;
|
|
}
|
|
}
|
|
if allow_scroll.any() {
|
|
let mut scroll_delta = ui.input(|i| i.smooth_scroll_delta);
|
|
if !allow_scroll.x {
|
|
scroll_delta.x = 0.0;
|
|
}
|
|
if !allow_scroll.y {
|
|
scroll_delta.y = 0.0;
|
|
}
|
|
if scroll_delta != Vec2::ZERO {
|
|
mem.transform.translate_bounds(-scroll_delta);
|
|
mem.auto_bounds = false.into();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- transform initialized
|
|
|
|
// Add legend widgets to plot
|
|
let bounds = mem.transform.bounds();
|
|
let x_axis_range = bounds.range_x();
|
|
let x_steps = Arc::new({
|
|
let input = GridInput {
|
|
bounds: (bounds.min[0], bounds.max[0]),
|
|
base_step_size: mem.transform.dvalue_dpos()[0].abs() * grid_spacing.min as f64,
|
|
};
|
|
(grid_spacers[0])(input)
|
|
});
|
|
let y_axis_range = bounds.range_y();
|
|
let y_steps = Arc::new({
|
|
let input = GridInput {
|
|
bounds: (bounds.min[1], bounds.max[1]),
|
|
base_step_size: mem.transform.dvalue_dpos()[1].abs() * grid_spacing.min as f64,
|
|
};
|
|
(grid_spacers[1])(input)
|
|
});
|
|
for (i, mut widget) in x_axis_widgets.into_iter().enumerate() {
|
|
widget.range = x_axis_range.clone();
|
|
widget.transform = Some(mem.transform);
|
|
widget.steps = x_steps.clone();
|
|
let (_response, thickness) = widget.ui(ui, Axis::X);
|
|
mem.x_axis_thickness.insert(i, thickness);
|
|
}
|
|
for (i, mut widget) in y_axis_widgets.into_iter().enumerate() {
|
|
widget.range = y_axis_range.clone();
|
|
widget.transform = Some(mem.transform);
|
|
widget.steps = y_steps.clone();
|
|
let (_response, thickness) = widget.ui(ui, Axis::Y);
|
|
mem.y_axis_thickness.insert(i, thickness);
|
|
}
|
|
|
|
// Initialize values from functions.
|
|
for item in &mut items {
|
|
item.initialize(mem.transform.bounds().range_x());
|
|
}
|
|
|
|
let prepared = PreparedPlot {
|
|
items,
|
|
show_x,
|
|
show_y,
|
|
label_formatter,
|
|
coordinates_formatter,
|
|
show_grid,
|
|
grid_spacing,
|
|
transform: mem.transform,
|
|
draw_cursor_x: linked_cursors.as_ref().map_or(false, |group| group.1.x),
|
|
draw_cursor_y: linked_cursors.as_ref().map_or(false, |group| group.1.y),
|
|
draw_cursors,
|
|
grid_spacers,
|
|
sharp_grid_lines,
|
|
clamp_grid,
|
|
};
|
|
|
|
let (plot_cursors, hovered_plot_item) = prepared.ui(ui, &response);
|
|
|
|
if let Some(boxed_zoom_rect) = boxed_zoom_rect {
|
|
ui.painter()
|
|
.with_clip_rect(plot_rect)
|
|
.add(boxed_zoom_rect.0);
|
|
ui.painter()
|
|
.with_clip_rect(plot_rect)
|
|
.add(boxed_zoom_rect.1);
|
|
}
|
|
|
|
if let Some(mut legend) = legend {
|
|
ui.add(&mut legend);
|
|
mem.hidden_items = legend.hidden_items();
|
|
mem.hovered_legend_item = legend.hovered_item_name();
|
|
}
|
|
|
|
if let Some((id, _)) = linked_cursors.as_ref() {
|
|
// Push the frame we just drew to the list of frames
|
|
ui.data_mut(|data| {
|
|
let frames: &mut CursorLinkGroups = data.get_temp_mut_or_default(Id::NULL);
|
|
let cursors = frames.0.entry(*id).or_default();
|
|
cursors.push(PlotFrameCursors {
|
|
id: plot_id,
|
|
cursors: plot_cursors,
|
|
});
|
|
});
|
|
}
|
|
|
|
if let Some((id, _)) = linked_axes.as_ref() {
|
|
// Save the linked bounds.
|
|
ui.data_mut(|data| {
|
|
let link_groups: &mut BoundsLinkGroups = data.get_temp_mut_or_default(Id::NULL);
|
|
link_groups.0.insert(
|
|
*id,
|
|
LinkedBounds {
|
|
bounds: *mem.transform.bounds(),
|
|
auto_bounds: mem.auto_bounds,
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
let transform = mem.transform;
|
|
mem.store(ui.ctx(), plot_id);
|
|
|
|
let response = if show_x || show_y {
|
|
response.on_hover_cursor(CursorIcon::Crosshair)
|
|
} else {
|
|
response
|
|
};
|
|
|
|
ui.advance_cursor_after_rect(complete_rect);
|
|
|
|
PlotResponse {
|
|
inner,
|
|
response,
|
|
transform,
|
|
hovered_plot_item,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the rect left after adding axes.
|
|
fn axis_widgets(
|
|
mem: Option<&PlotMemory>,
|
|
show_axes: Vec2b,
|
|
complete_rect: Rect,
|
|
[x_axes, y_axes]: [&[AxisHints]; 2],
|
|
) -> ([Vec<AxisWidget>; 2], Rect) {
|
|
// Next we want to create this layout.
|
|
// Indices are only examples.
|
|
//
|
|
// left right
|
|
// +---+---------x----------+ +
|
|
// | | X-axis 3 |
|
|
// | +--------------------+ top
|
|
// | | X-axis 2 |
|
|
// +-+-+--------------------+-+-+
|
|
// |y|y| |y|y|
|
|
// |-|-| |-|-|
|
|
// |A|A| |A|A|
|
|
// y|x|x| Plot Window |x|x|
|
|
// |i|i| |i|i|
|
|
// |s|s| |s|s|
|
|
// |1|0| |2|3|
|
|
// +-+-+--------------------+-+-+
|
|
// | X-axis 0 | |
|
|
// +--------------------+ | bottom
|
|
// | X-axis 1 | |
|
|
// + +--------------------+---+
|
|
//
|
|
|
|
let mut x_axis_widgets = Vec::<AxisWidget>::new();
|
|
let mut y_axis_widgets = Vec::<AxisWidget>::new();
|
|
|
|
// Will shrink as we add more axes.
|
|
let mut rect_left = complete_rect;
|
|
|
|
if show_axes.x {
|
|
// We will fix this later, once we know how much space the y axes take up.
|
|
let initial_x_range = complete_rect.x_range();
|
|
|
|
for (i, cfg) in x_axes.iter().enumerate().rev() {
|
|
let mut height = cfg.thickness(Axis::X);
|
|
if let Some(mem) = mem {
|
|
// If the labels took up too much space the previous frame, give them more space now:
|
|
height = height.max(mem.x_axis_thickness.get(&i).copied().unwrap_or_default());
|
|
}
|
|
|
|
let rect = match VPlacement::from(cfg.placement) {
|
|
VPlacement::Bottom => {
|
|
let bottom = rect_left.bottom();
|
|
*rect_left.bottom_mut() -= height;
|
|
let top = rect_left.bottom();
|
|
Rect::from_x_y_ranges(initial_x_range, top..=bottom)
|
|
}
|
|
VPlacement::Top => {
|
|
let top = rect_left.top();
|
|
*rect_left.top_mut() += height;
|
|
let bottom = rect_left.top();
|
|
Rect::from_x_y_ranges(initial_x_range, top..=bottom)
|
|
}
|
|
};
|
|
x_axis_widgets.push(AxisWidget::new(cfg.clone(), rect));
|
|
}
|
|
}
|
|
if show_axes.y {
|
|
// We know this, since we've already allocated space for the x axes.
|
|
let plot_y_range = rect_left.y_range();
|
|
|
|
for (i, cfg) in y_axes.iter().enumerate().rev() {
|
|
let mut width = cfg.thickness(Axis::Y);
|
|
if let Some(mem) = mem {
|
|
// If the labels took up too much space the previous frame, give them more space now:
|
|
width = width.max(mem.y_axis_thickness.get(&i).copied().unwrap_or_default());
|
|
}
|
|
|
|
let rect = match HPlacement::from(cfg.placement) {
|
|
HPlacement::Left => {
|
|
let left = rect_left.left();
|
|
*rect_left.left_mut() += width;
|
|
let right = rect_left.left();
|
|
Rect::from_x_y_ranges(left..=right, plot_y_range)
|
|
}
|
|
HPlacement::Right => {
|
|
let right = rect_left.right();
|
|
*rect_left.right_mut() -= width;
|
|
let left = rect_left.right();
|
|
Rect::from_x_y_ranges(left..=right, plot_y_range)
|
|
}
|
|
};
|
|
y_axis_widgets.push(AxisWidget::new(cfg.clone(), rect));
|
|
}
|
|
}
|
|
|
|
let mut plot_rect = rect_left;
|
|
|
|
// If too little space, remove axis widgets
|
|
if plot_rect.width() <= 0.0 || plot_rect.height() <= 0.0 {
|
|
y_axis_widgets.clear();
|
|
x_axis_widgets.clear();
|
|
plot_rect = complete_rect;
|
|
}
|
|
|
|
// Bow that we know the final x_range of the plot_rect,
|
|
// assign it to the x_axis_widgets (they are currently too wide):
|
|
for widget in &mut x_axis_widgets {
|
|
widget.rect = Rect::from_x_y_ranges(plot_rect.x_range(), widget.rect.y_range());
|
|
}
|
|
|
|
([x_axis_widgets, y_axis_widgets], plot_rect)
|
|
}
|
|
|
|
/// User-requested modifications to the plot bounds. We collect them in the plot build function to later apply
|
|
/// them at the right time, as other modifications need to happen first.
|
|
enum BoundsModification {
|
|
Set(PlotBounds),
|
|
Translate(Vec2),
|
|
AutoBounds(Vec2b),
|
|
Zoom(Vec2, PlotPoint),
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Grid
|
|
|
|
/// Input for "grid spacer" functions.
|
|
///
|
|
/// See [`Plot::x_grid_spacer()`] and [`Plot::y_grid_spacer()`].
|
|
pub struct GridInput {
|
|
/// Min/max of the visible data range (the values at the two edges of the plot,
|
|
/// for the current axis).
|
|
pub bounds: (f64, f64),
|
|
|
|
/// Recommended (but not required) lower-bound on the step size returned by custom grid spacers.
|
|
///
|
|
/// Computed as the ratio between the diagram's bounds (in plot coordinates) and the viewport
|
|
/// (in frame/window coordinates), scaled up to represent the minimal possible step.
|
|
///
|
|
/// Always positive.
|
|
pub base_step_size: f64,
|
|
}
|
|
|
|
/// One mark (horizontal or vertical line) in the background grid of a plot.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct GridMark {
|
|
/// X or Y value in the plot.
|
|
pub value: f64,
|
|
|
|
/// The (approximate) distance to the next value of same thickness.
|
|
///
|
|
/// Determines how thick the grid line is painted. It's not important that `step_size`
|
|
/// matches the difference between two `value`s precisely, but rather that grid marks of
|
|
/// same thickness have same `step_size`. For example, months can have a different number
|
|
/// of days, but consistently using a `step_size` of 30 days is a valid approximation.
|
|
pub step_size: f64,
|
|
}
|
|
|
|
/// Recursively splits the grid into `base` subdivisions (e.g. 100, 10, 1).
|
|
///
|
|
/// The logarithmic base, expressing how many times each grid unit is subdivided.
|
|
/// 10 is a typical value, others are possible though.
|
|
pub fn log_grid_spacer(log_base: i64) -> GridSpacer {
|
|
let log_base = log_base as f64;
|
|
let step_sizes = move |input: GridInput| -> Vec<GridMark> {
|
|
// The distance between two of the thinnest grid lines is "rounded" up
|
|
// to the next-bigger power of base
|
|
let smallest_visible_unit = next_power(input.base_step_size, log_base);
|
|
|
|
let step_sizes = [
|
|
smallest_visible_unit,
|
|
smallest_visible_unit * log_base,
|
|
smallest_visible_unit * log_base * log_base,
|
|
];
|
|
|
|
generate_marks(step_sizes, input.bounds)
|
|
};
|
|
|
|
Box::new(step_sizes)
|
|
}
|
|
|
|
/// Splits the grid into uniform-sized spacings (e.g. 100, 25, 1).
|
|
///
|
|
/// This function should return 3 positive step sizes, designating where the lines in the grid are drawn.
|
|
/// Lines are thicker for larger step sizes. Ordering of returned value is irrelevant.
|
|
///
|
|
/// Why only 3 step sizes? Three is the number of different line thicknesses that egui typically uses in the grid.
|
|
/// Ideally, those 3 are not hardcoded values, but depend on the visible range (accessible through `GridInput`).
|
|
pub fn uniform_grid_spacer(spacer: impl Fn(GridInput) -> [f64; 3] + 'static) -> GridSpacer {
|
|
let get_marks = move |input: GridInput| -> Vec<GridMark> {
|
|
let bounds = input.bounds;
|
|
let step_sizes = spacer(input);
|
|
generate_marks(step_sizes, bounds)
|
|
};
|
|
|
|
Box::new(get_marks)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
struct PreparedPlot {
|
|
items: Vec<Box<dyn PlotItem>>,
|
|
show_x: bool,
|
|
show_y: bool,
|
|
label_formatter: LabelFormatter,
|
|
coordinates_formatter: Option<(Corner, CoordinatesFormatter)>,
|
|
// axis_formatters: [AxisFormatter; 2],
|
|
transform: PlotTransform,
|
|
show_grid: Vec2b,
|
|
grid_spacing: Rangef,
|
|
grid_spacers: [GridSpacer; 2],
|
|
draw_cursor_x: bool,
|
|
draw_cursor_y: bool,
|
|
draw_cursors: Vec<Cursor>,
|
|
|
|
sharp_grid_lines: bool,
|
|
clamp_grid: bool,
|
|
}
|
|
|
|
impl PreparedPlot {
|
|
fn ui(self, ui: &mut Ui, response: &Response) -> (Vec<Cursor>, Option<Id>) {
|
|
let mut axes_shapes = Vec::new();
|
|
|
|
if self.show_grid.x {
|
|
self.paint_grid(ui, &mut axes_shapes, Axis::X, self.grid_spacing);
|
|
}
|
|
if self.show_grid.y {
|
|
self.paint_grid(ui, &mut axes_shapes, Axis::Y, self.grid_spacing);
|
|
}
|
|
|
|
// Sort the axes by strength so that those with higher strength are drawn in front.
|
|
axes_shapes.sort_by(|(_, strength1), (_, strength2)| strength1.total_cmp(strength2));
|
|
|
|
let mut shapes = axes_shapes.into_iter().map(|(shape, _)| shape).collect();
|
|
|
|
let transform = &self.transform;
|
|
|
|
let mut plot_ui = ui.child_ui(*transform.frame(), Layout::default());
|
|
plot_ui.set_clip_rect(transform.frame().intersect(ui.clip_rect()));
|
|
for item in &self.items {
|
|
item.shapes(&plot_ui, transform, &mut shapes);
|
|
}
|
|
|
|
let hover_pos = response.hover_pos();
|
|
let (cursors, hovered_item_id) = if let Some(pointer) = hover_pos {
|
|
self.hover(ui, pointer, &mut shapes)
|
|
} else {
|
|
(Vec::new(), None)
|
|
};
|
|
|
|
// Draw cursors
|
|
let line_color = rulers_color(ui);
|
|
|
|
let mut draw_cursor = |cursors: &Vec<Cursor>, always| {
|
|
for &cursor in cursors {
|
|
match cursor {
|
|
Cursor::Horizontal { y } => {
|
|
if self.draw_cursor_y || always {
|
|
shapes.push(horizontal_line(
|
|
transform.position_from_point(&PlotPoint::new(0.0, y)),
|
|
&self.transform,
|
|
line_color,
|
|
));
|
|
}
|
|
}
|
|
Cursor::Vertical { x } => {
|
|
if self.draw_cursor_x || always {
|
|
shapes.push(vertical_line(
|
|
transform.position_from_point(&PlotPoint::new(x, 0.0)),
|
|
&self.transform,
|
|
line_color,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
draw_cursor(&self.draw_cursors, false);
|
|
draw_cursor(&cursors, true);
|
|
|
|
let painter = ui.painter().with_clip_rect(*transform.frame());
|
|
painter.extend(shapes);
|
|
|
|
if let Some((corner, formatter)) = self.coordinates_formatter.as_ref() {
|
|
let hover_pos = response.hover_pos();
|
|
if let Some(pointer) = hover_pos {
|
|
let font_id = TextStyle::Monospace.resolve(ui.style());
|
|
let coordinate = transform.value_from_position(pointer);
|
|
let text = formatter.format(&coordinate, transform.bounds());
|
|
let padded_frame = transform.frame().shrink(4.0);
|
|
let (anchor, position) = match corner {
|
|
Corner::LeftTop => (Align2::LEFT_TOP, padded_frame.left_top()),
|
|
Corner::RightTop => (Align2::RIGHT_TOP, padded_frame.right_top()),
|
|
Corner::LeftBottom => (Align2::LEFT_BOTTOM, padded_frame.left_bottom()),
|
|
Corner::RightBottom => (Align2::RIGHT_BOTTOM, padded_frame.right_bottom()),
|
|
};
|
|
painter.text(position, anchor, text, font_id, ui.visuals().text_color());
|
|
}
|
|
}
|
|
|
|
(cursors, hovered_item_id)
|
|
}
|
|
|
|
fn paint_grid(&self, ui: &Ui, shapes: &mut Vec<(Shape, f32)>, axis: Axis, fade_range: Rangef) {
|
|
#![allow(clippy::collapsible_else_if)]
|
|
let Self {
|
|
transform,
|
|
// axis_formatters,
|
|
grid_spacers,
|
|
clamp_grid,
|
|
..
|
|
} = self;
|
|
|
|
let iaxis = usize::from(axis);
|
|
|
|
// Where on the cross-dimension to show the label values
|
|
let bounds = transform.bounds();
|
|
let value_cross = 0.0_f64.clamp(bounds.min[1 - iaxis], bounds.max[1 - iaxis]);
|
|
|
|
let input = GridInput {
|
|
bounds: (bounds.min[iaxis], bounds.max[iaxis]),
|
|
base_step_size: transform.dvalue_dpos()[iaxis].abs() * fade_range.min as f64,
|
|
};
|
|
let steps = (grid_spacers[iaxis])(input);
|
|
|
|
let clamp_range = clamp_grid.then(|| {
|
|
let mut tight_bounds = PlotBounds::NOTHING;
|
|
for item in &self.items {
|
|
let item_bounds = item.bounds();
|
|
tight_bounds.merge_x(&item_bounds);
|
|
tight_bounds.merge_y(&item_bounds);
|
|
}
|
|
tight_bounds
|
|
});
|
|
|
|
for step in steps {
|
|
let value_main = step.value;
|
|
|
|
if let Some(clamp_range) = clamp_range {
|
|
match axis {
|
|
Axis::X => {
|
|
if !clamp_range.range_x().contains(&value_main) {
|
|
continue;
|
|
};
|
|
}
|
|
Axis::Y => {
|
|
if !clamp_range.range_y().contains(&value_main) {
|
|
continue;
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
let value = match axis {
|
|
Axis::X => PlotPoint::new(value_main, value_cross),
|
|
Axis::Y => PlotPoint::new(value_cross, value_main),
|
|
};
|
|
|
|
let pos_in_gui = transform.position_from_point(&value);
|
|
let spacing_in_points = (transform.dpos_dvalue()[iaxis] * step.step_size).abs() as f32;
|
|
|
|
if spacing_in_points <= fade_range.min {
|
|
continue; // Too close together
|
|
}
|
|
|
|
let line_strength = remap_clamp(spacing_in_points, fade_range, 0.0..=1.0);
|
|
|
|
let line_color = color_from_strength(ui, line_strength);
|
|
|
|
let mut p0 = pos_in_gui;
|
|
let mut p1 = pos_in_gui;
|
|
p0[1 - iaxis] = transform.frame().min[1 - iaxis];
|
|
p1[1 - iaxis] = transform.frame().max[1 - iaxis];
|
|
|
|
if let Some(clamp_range) = clamp_range {
|
|
match axis {
|
|
Axis::X => {
|
|
p0.y = transform.position_from_point_y(clamp_range.min[1]);
|
|
p1.y = transform.position_from_point_y(clamp_range.max[1]);
|
|
}
|
|
Axis::Y => {
|
|
p0.x = transform.position_from_point_x(clamp_range.min[0]);
|
|
p1.x = transform.position_from_point_x(clamp_range.max[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.sharp_grid_lines {
|
|
// Round to avoid aliasing
|
|
p0 = ui.painter().round_pos_to_pixels(p0);
|
|
p1 = ui.painter().round_pos_to_pixels(p1);
|
|
}
|
|
|
|
shapes.push((
|
|
Shape::line_segment([p0, p1], Stroke::new(1.0, line_color)),
|
|
line_strength,
|
|
));
|
|
}
|
|
}
|
|
|
|
fn hover(&self, ui: &Ui, pointer: Pos2, shapes: &mut Vec<Shape>) -> (Vec<Cursor>, Option<Id>) {
|
|
let Self {
|
|
transform,
|
|
show_x,
|
|
show_y,
|
|
label_formatter,
|
|
items,
|
|
..
|
|
} = self;
|
|
|
|
if !show_x && !show_y {
|
|
return (Vec::new(), None);
|
|
}
|
|
|
|
let interact_radius_sq = (16.0_f32).powi(2);
|
|
|
|
let candidates = items.iter().filter_map(|item| {
|
|
let item = &**item;
|
|
let closest = item.find_closest(pointer, transform);
|
|
|
|
Some(item).zip(closest)
|
|
});
|
|
|
|
let closest = candidates
|
|
.min_by_key(|(_, elem)| elem.dist_sq.ord())
|
|
.filter(|(_, elem)| elem.dist_sq <= interact_radius_sq);
|
|
|
|
let plot = items::PlotConfig {
|
|
ui,
|
|
transform,
|
|
show_x: *show_x,
|
|
show_y: *show_y,
|
|
};
|
|
|
|
let mut cursors = Vec::new();
|
|
|
|
let hovered_plot_item_id = if let Some((item, elem)) = closest {
|
|
item.on_hover(elem, shapes, &mut cursors, &plot, label_formatter);
|
|
item.id()
|
|
} else {
|
|
let value = transform.value_from_position(pointer);
|
|
items::rulers_at_value(
|
|
pointer,
|
|
value,
|
|
"",
|
|
&plot,
|
|
shapes,
|
|
&mut cursors,
|
|
label_formatter,
|
|
);
|
|
None
|
|
};
|
|
|
|
(cursors, hovered_plot_item_id)
|
|
}
|
|
}
|
|
|
|
/// Returns next bigger power in given base
|
|
/// e.g.
|
|
/// ```ignore
|
|
/// use egui_plot::next_power;
|
|
/// assert_eq!(next_power(0.01, 10.0), 0.01);
|
|
/// assert_eq!(next_power(0.02, 10.0), 0.1);
|
|
/// assert_eq!(next_power(0.2, 10.0), 1);
|
|
/// ```
|
|
fn next_power(value: f64, base: f64) -> f64 {
|
|
assert_ne!(value, 0.0); // can be negative (typical for Y axis)
|
|
base.powi(value.abs().log(base).ceil() as i32)
|
|
}
|
|
|
|
/// Fill in all values between [min, max] which are a multiple of `step_size`
|
|
fn generate_marks(step_sizes: [f64; 3], bounds: (f64, f64)) -> Vec<GridMark> {
|
|
let mut steps = vec![];
|
|
fill_marks_between(&mut steps, step_sizes[0], bounds);
|
|
fill_marks_between(&mut steps, step_sizes[1], bounds);
|
|
fill_marks_between(&mut steps, step_sizes[2], bounds);
|
|
steps
|
|
}
|
|
|
|
/// Fill in all values between [min, max] which are a multiple of `step_size`
|
|
fn fill_marks_between(out: &mut Vec<GridMark>, step_size: f64, (min, max): (f64, f64)) {
|
|
assert!(max > min);
|
|
let first = (min / step_size).ceil() as i64;
|
|
let last = (max / step_size).ceil() as i64;
|
|
|
|
let marks_iter = (first..last).map(|i| {
|
|
let value = (i as f64) * step_size;
|
|
GridMark { value, step_size }
|
|
});
|
|
out.extend(marks_iter);
|
|
}
|
|
|
|
/// Helper for formatting a number so that we always show at least a few decimals,
|
|
/// unless it is an integer, in which case we never show any decimals.
|
|
pub fn format_number(number: f64, num_decimals: usize) -> String {
|
|
let is_integral = number as i64 as f64 == number;
|
|
if is_integral {
|
|
// perfect integer - show it as such:
|
|
format!("{number:.0}")
|
|
} else {
|
|
// make sure we tell the user it is not an integer by always showing a decimal or two:
|
|
format!("{:.*}", num_decimals.at_least(1), number)
|
|
}
|
|
}
|
|
|
|
/// Determine a color from a 0-1 strength value.
|
|
pub fn color_from_strength(ui: &Ui, strength: f32) -> Color32 {
|
|
let base_color = ui.visuals().text_color();
|
|
base_color.gamma_multiply(strength.sqrt())
|
|
}
|