Draw axis labels and ticks outside of plotting window (#2284)

* Always draw axis labels at plot borders

* Revert "Always draw axis labels at plot borders"

This reverts commit 9235e6603366d3b8a8189e2a5fc28c9780b7f54f.

* Add axis labels for plots

* First Draft of axis labels outside of plotting window

* plot: Tick placement of opposite axes and digit constraints

* plot: Axis label API

* plot: Update demo lib

* plot: resolve clippy warning

* Update changelog

* Remove default axis

* Fix clippy

* plot: Remove unused comments

* plot-axis: Rebase label opacity calculation on master

* plot: Resolve check.sh warnings

* plot-axis: Use 'into impl<WidgetText>' as axis label formatter

* plot-axis: Expose more conveniece functions to public API. Add axis labels to demo app

* plot-axes: Resolve ./scripts/check.sh warnings

* typo in comment

* Use `TAU` instead of the legacy `PI`

* Simpler generic syntax

* Use `Arc` to avoid some expensive clones

* Use `Margin` instead of a,b,c,d

* Add some vertical spacing

* De-duplicate color_from_contrast

* better naming

* Fix typos

* cnt -> num

* Axis are present by default, with empty names

* Add HPlacement and VPlacement

* Don't catch clicks and drags on axes

* Remove generics to minimize monomorphization code bloat

* Create helper function

* Remove changelog entry

* Simplify more

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
JohannesProgrammiert 2023-08-14 17:51:17 +02:00 committed by GitHub
parent a3ae81cadb
commit dbe55ba46a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 807 additions and 202 deletions

View File

@ -338,6 +338,13 @@ pub struct Margin {
}
impl Margin {
pub const ZERO: Self = Self {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
};
#[inline]
pub fn same(margin: f32) -> Self {
Self {

View File

@ -38,6 +38,8 @@ pub use text_edit::{TextBuffer, TextEdit};
///
/// [`Button`], [`Label`], [`Slider`], etc all implement the [`Widget`] trait.
///
/// You only need to implement `Widget` if you care about being able to do `ui.add(your_widget);`.
///
/// Note that the widgets ([`Button`], [`TextEdit`] etc) are
/// [builders](https://doc.rust-lang.org/1.0.0/style/ownership/builders.html),
/// and not objects that hold state.

View File

@ -0,0 +1,318 @@
use std::{fmt::Debug, ops::RangeInclusive, sync::Arc};
use epaint::{
emath::{remap_clamp, round_to_decimals},
Pos2, Rect, Shape, Stroke, TextShape,
};
use crate::{Response, Sense, TextStyle, Ui, WidgetText};
use super::{transform::PlotTransform, GridMark};
pub(super) type AxisFormatterFn = 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: 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: 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: fn(f64, usize, &RangeInclusive<f64>) -> String) -> Self {
self.formatter = 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,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

@ -1,15 +1,17 @@
//! Simple plotting library.
use ahash::HashMap;
use std::ops::RangeInclusive;
use std::{ops::RangeInclusive, sync::Arc};
use crate::*;
use ahash::HashMap;
use epaint::util::FloatOrd;
use epaint::Hsva;
use axis::AxisWidget;
use items::PlotItem;
use legend::LegendWidget;
use crate::*;
pub use items::{
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, HLine, Line, LineStyle, MarkerShape,
Orientation, PlotImage, PlotPoint, PlotPoints, Points, Polygon, Text, VLine,
@ -17,16 +19,17 @@ pub use items::{
pub use legend::{Corner, Legend};
pub use transform::{PlotBounds, PlotTransform};
use self::items::{horizontal_line, rulers_color, vertical_line};
use items::{horizontal_line, rulers_color, vertical_line};
pub use axis::{Axis, AxisHints, HPlacement, Placement, VPlacement};
mod axis;
mod items;
mod legend;
mod transform;
type LabelFormatterFn = dyn Fn(&str, &PlotPoint) -> String;
type LabelFormatter = Option<Box<LabelFormatterFn>>;
type AxisFormatterFn = dyn Fn(f64, &RangeInclusive<f64>) -> String;
type AxisFormatter = Option<Box<AxisFormatterFn>>;
type GridSpacerFn = dyn Fn(GridInput) -> Vec<GridMark>;
type GridSpacer = Box<GridSpacerFn>;
@ -78,6 +81,7 @@ pub struct AxisBools {
}
impl AxisBools {
#[inline]
pub fn new(x: bool, y: bool) -> Self {
Self { x, y }
}
@ -89,11 +93,19 @@ impl AxisBools {
}
impl From<bool> for AxisBools {
#[inline]
fn from(val: bool) -> Self {
AxisBools { x: val, y: val }
}
}
impl From<[bool; 2]> for AxisBools {
#[inline]
fn from([x, y]: [bool; 2]) -> Self {
AxisBools { x, y }
}
}
/// Information about the plot that has to persist between frames.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone)]
@ -182,8 +194,7 @@ pub struct PlotResponse<R> {
pub struct Plot {
id_source: Id,
center_x_axis: bool,
center_y_axis: bool,
center_axis: AxisBools,
allow_zoom: AxisBools,
allow_drag: AxisBools,
allow_scroll: bool,
@ -208,11 +219,12 @@ pub struct Plot {
show_y: bool,
label_formatter: LabelFormatter,
coordinates_formatter: Option<(Corner, CoordinatesFormatter)>,
axis_formatters: [AxisFormatter; 2],
x_axes: Vec<AxisHints>, // default x axes
y_axes: Vec<AxisHints>, // default y axes
legend_config: Option<Legend>,
show_background: bool,
show_axes: [bool; 2],
show_axes: AxisBools,
show_grid: AxisBools,
grid_spacers: [GridSpacer; 2],
sharp_grid_lines: bool,
clamp_grid: bool,
@ -224,8 +236,7 @@ impl Plot {
Self {
id_source: Id::new(id_source),
center_x_axis: false,
center_y_axis: false,
center_axis: false.into(),
allow_zoom: true.into(),
allow_drag: true.into(),
allow_scroll: true,
@ -250,11 +261,12 @@ impl Plot {
show_y: true,
label_formatter: None,
coordinates_formatter: None,
axis_formatters: [None, None], // [None; 2] requires Copy
x_axes: vec![Default::default()],
y_axes: vec![Default::default()],
legend_config: None,
show_background: true,
show_axes: [true; 2],
show_axes: true.into(),
show_grid: true.into(),
grid_spacers: [log_grid_spacer(10), log_grid_spacer(10)],
sharp_grid_lines: true,
clamp_grid: false,
@ -311,15 +323,15 @@ impl Plot {
self
}
/// Always keep the x-axis centered. Default: `false`.
/// Always keep the X-axis centered. Default: `false`.
pub fn center_x_axis(mut self, on: bool) -> Self {
self.center_x_axis = on;
self.center_axis.x = on;
self
}
/// Always keep the y-axis centered. Default: `false`.
/// Always keep the Y-axis centered. Default: `false`.
pub fn center_y_axis(mut self, on: bool) -> Self {
self.center_y_axis = on;
self.center_axis.y = on;
self
}
@ -417,36 +429,6 @@ impl Plot {
self
}
/// Provide a function to customize the labels for the X axis based on the current visible value range.
///
/// This is useful for custom input domains, e.g. date/time.
///
/// If axis labels should not appear for certain values or beyond a certain zoom/resolution,
/// the formatter function can return empty strings. This is also useful if your domain is
/// discrete (e.g. only full days in a calendar).
pub fn x_axis_formatter(
mut self,
func: impl Fn(f64, &RangeInclusive<f64>) -> String + 'static,
) -> Self {
self.axis_formatters[0] = Some(Box::new(func));
self
}
/// Provide a function to customize the labels for the Y axis based on the current value range.
///
/// This is useful for custom value representation, e.g. percentage or units.
///
/// If axis labels should not appear for certain values or beyond a certain zoom/resolution,
/// the formatter function can return empty strings. This is also useful if your Y values are
/// discrete (e.g. only integers).
pub fn y_axis_formatter(
mut self,
func: impl Fn(f64, &RangeInclusive<f64>) -> String + 'static,
) -> Self {
self.axis_formatters[1] = Some(Box::new(func));
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.
@ -538,11 +520,19 @@ impl Plot {
self
}
/// Show the axes.
/// Can be useful to disable if the plot is overlaid over an existing grid or content.
/// Show axis labels and grid tick values on the side of the plot.
///
/// Default: `[true; 2]`.
pub fn show_axes(mut self, show: [bool; 2]) -> Self {
self.show_axes = show;
pub fn show_axes(mut self, show: impl Into<AxisBools>) -> Self {
self.show_axes = show.into();
self
}
/// Show a grid overlay on the plot.
///
/// Default: `[true; 2]`.
pub fn show_grid(mut self, show: impl Into<AxisBools>) -> Self {
self.show_grid = show.into();
self
}
@ -585,6 +575,94 @@ impl Plot {
self
}
/// Set the x axis label of the main X-axis.
///
/// Default: no label.
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.
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.
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.
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.
///
/// The first parameter of `fmt` is the raw tick value as `f64`.
/// The second parameter is the maximum requested number of characters per tick label.
/// The second parameter of `fmt` is the currently shown range on this axis.
pub fn x_axis_formatter(mut self, fmt: fn(f64, usize, &RangeInclusive<f64>) -> String) -> Self {
if let Some(main) = self.x_axes.first_mut() {
main.formatter = fmt;
}
self
}
/// Specify custom formatter for ticks on the main Y-axis.
///
/// The first parameter of `formatter` is the raw tick value as `f64`.
/// The second parameter is the maximum requested number of characters per tick label.
/// The second parameter of `formatter` is the currently shown range on this axis.
pub fn y_axis_formatter(mut self, fmt: fn(f64, usize, &RangeInclusive<f64>) -> String) -> Self {
if let Some(main) = self.y_axes.first_mut() {
main.formatter = 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.
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.
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.
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))
@ -597,8 +675,7 @@ impl Plot {
) -> PlotResponse<R> {
let Self {
id_source,
center_x_axis,
center_y_axis,
center_axis,
allow_zoom,
allow_drag,
allow_scroll,
@ -617,11 +694,13 @@ impl Plot {
mut show_y,
label_formatter,
coordinates_formatter,
axis_formatters,
x_axes,
y_axes,
legend_config,
reset,
show_background,
show_axes,
show_grid,
linked_axes,
linked_cursors,
@ -630,7 +709,9 @@ impl Plot {
sharp_grid_lines,
} = self;
// Determine the size of the plot in the UI
// Determine position of widget.
let pos = ui.available_rect_before_wrap().min;
// Determine size of widget.
let size = {
let width = width
.unwrap_or_else(|| {
@ -653,9 +734,79 @@ impl Plot {
.at_least(min_size.y);
vec2(width, height)
};
// Determine complete rect of widget.
let complete_rect = Rect {
min: pos,
max: pos + size,
};
// Next we want to create this layout.
// Incides 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 | |
// + +--------------------+---+
//
// Allocate the space.
let (rect, response) = ui.allocate_exact_size(size, Sense::drag());
let mut plot_rect: Rect = {
// Calcuclate the space needed for each axis labels.
let mut margin = Margin::ZERO;
if show_axes.x {
for cfg in &x_axes {
match cfg.placement {
axis::Placement::LeftBottom => {
margin.bottom += cfg.thickness(Axis::X);
}
axis::Placement::RightTop => {
margin.top += cfg.thickness(Axis::X);
}
}
}
}
if show_axes.y {
for cfg in &y_axes {
match cfg.placement {
axis::Placement::LeftBottom => {
margin.left += cfg.thickness(Axis::Y);
}
axis::Placement::RightTop => {
margin.right += cfg.thickness(Axis::Y);
}
}
}
}
// determine plot rectangle
margin.shrink_rect(complete_rect)
};
let [mut x_axis_widgets, mut y_axis_widgets] =
axis_widgets(show_axes, plot_rect, [&x_axes, &y_axes]);
// 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;
}
// Allocate the plot window.
let response = ui.allocate_rect(plot_rect, Sense::drag());
let rect = plot_rect;
// Load or initialize the memory.
let plot_id = ui.make_persistent_id(id_source);
@ -679,8 +830,8 @@ impl Plot {
last_plot_transform: PlotTransform::new(
rect,
min_auto_bounds,
center_x_axis,
center_y_axis,
center_axis.x,
center_axis.y,
),
last_click_pos_for_zoom: None,
});
@ -841,7 +992,7 @@ impl Plot {
}
}
let mut transform = PlotTransform::new(rect, bounds, center_x_axis, center_y_axis);
let mut transform = PlotTransform::new(rect, bounds, center_axis.x, center_axis.y);
// Enforce aspect ratio
if let Some(data_aspect) = data_aspect {
@ -949,6 +1100,39 @@ impl Plot {
}
}
// --- transform initialized
// Add legend widgets to plot
let bounds = 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: transform.dvalue_dpos()[0] * MIN_LINE_SPACING_IN_POINTS * 2.0,
};
(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: transform.dvalue_dpos()[1] * MIN_LINE_SPACING_IN_POINTS * 2.0,
};
(grid_spacers[1])(input)
});
for mut widget in x_axis_widgets {
widget.range = x_axis_range.clone();
widget.transform = Some(transform);
widget.steps = x_steps.clone();
widget.ui(ui, Axis::X);
}
for mut widget in y_axis_widgets {
widget.range = y_axis_range.clone();
widget.transform = Some(transform);
widget.steps = y_steps.clone();
widget.ui(ui, Axis::Y);
}
// Initialize values from functions.
for item in &mut items {
item.initialize(transform.bounds().range_x());
@ -960,16 +1144,16 @@ impl Plot {
show_y,
label_formatter,
coordinates_formatter,
axis_formatters,
show_axes,
show_grid,
transform,
draw_cursor_x: linked_cursors.as_ref().map_or(false, |(_, group)| group.x),
draw_cursor_y: linked_cursors.as_ref().map_or(false, |(_, group)| group.y),
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 = prepared.ui(ui, &response);
if let Some(boxed_zoom_rect) = boxed_zoom_rect {
@ -1024,7 +1208,7 @@ impl Plot {
} else {
response
};
ui.advance_cursor_after_rect(complete_rect);
PlotResponse {
inner,
response,
@ -1033,6 +1217,79 @@ impl Plot {
}
}
fn axis_widgets(
show_axes: AxisBools,
plot_rect: Rect,
[x_axes, y_axes]: [&[AxisHints]; 2],
) -> [Vec<AxisWidget>; 2] {
let mut x_axis_widgets = Vec::<AxisWidget>::new();
let mut y_axis_widgets = Vec::<AxisWidget>::new();
// Widget count per border of plot in order left, top, right, bottom
struct NumWidgets {
left: usize,
top: usize,
right: usize,
bottom: usize,
}
let mut num_widgets = NumWidgets {
left: 0,
top: 0,
right: 0,
bottom: 0,
};
if show_axes.x {
for cfg in x_axes {
let size_y = Vec2::new(0.0, cfg.thickness(Axis::X));
let rect = match cfg.placement {
axis::Placement::LeftBottom => {
let off = num_widgets.bottom as f32;
num_widgets.bottom += 1;
Rect {
min: plot_rect.left_bottom() + size_y * off,
max: plot_rect.right_bottom() + size_y * (off + 1.0),
}
}
axis::Placement::RightTop => {
let off = num_widgets.top as f32;
num_widgets.top += 1;
Rect {
min: plot_rect.left_top() - size_y * (off + 1.0),
max: plot_rect.right_top() - size_y * off,
}
}
};
x_axis_widgets.push(AxisWidget::new(cfg.clone(), rect));
}
}
if show_axes.y {
for cfg in y_axes {
let size_x = Vec2::new(cfg.thickness(Axis::Y), 0.0);
let rect = match cfg.placement {
axis::Placement::LeftBottom => {
let off = num_widgets.left as f32;
num_widgets.left += 1;
Rect {
min: plot_rect.left_top() - size_x * (off + 1.0),
max: plot_rect.left_bottom() - size_x * off,
}
}
axis::Placement::RightTop => {
let off = num_widgets.right as f32;
num_widgets.right += 1;
Rect {
min: plot_rect.right_top() + size_x * off,
max: plot_rect.right_bottom() + size_x * (off + 1.0),
}
}
};
y_axis_widgets.push(AxisWidget::new(cfg.clone(), rect));
}
}
[x_axis_widgets, y_axis_widgets]
}
/// 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 {
@ -1268,6 +1525,7 @@ pub struct GridInput {
}
/// 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,
@ -1329,14 +1587,14 @@ struct PreparedPlot {
show_y: bool,
label_formatter: LabelFormatter,
coordinates_formatter: Option<(Corner, CoordinatesFormatter)>,
axis_formatters: [AxisFormatter; 2],
show_axes: [bool; 2],
// axis_formatters: [AxisFormatter; 2],
transform: PlotTransform,
show_grid: AxisBools,
grid_spacers: [GridSpacer; 2],
draw_cursor_x: bool,
draw_cursor_y: bool,
draw_cursors: Vec<Cursor>,
grid_spacers: [GridSpacer; 2],
sharp_grid_lines: bool,
clamp_grid: bool,
}
@ -1345,16 +1603,11 @@ impl PreparedPlot {
fn ui(self, ui: &mut Ui, response: &Response) -> Vec<Cursor> {
let mut axes_shapes = Vec::new();
for d in 0..2 {
if self.show_axes[d] {
self.paint_axis(
ui,
d,
self.show_axes[1 - d],
&mut axes_shapes,
self.sharp_grid_lines,
);
}
if self.show_grid.x {
self.paint_grid(ui, &mut axes_shapes, Axis::X);
}
if self.show_grid.y {
self.paint_grid(ui, &mut axes_shapes, Axis::Y);
}
// Sort the axes by strength so that those with higher strength are drawn in front.
@ -1431,41 +1684,27 @@ impl PreparedPlot {
cursors
}
fn paint_axis(
&self,
ui: &Ui,
axis: usize,
other_axis_shown: bool,
shapes: &mut Vec<(Shape, f32)>,
sharp_grid_lines: bool,
) {
fn paint_grid(&self, ui: &Ui, shapes: &mut Vec<(Shape, f32)>, axis: Axis) {
#![allow(clippy::collapsible_else_if)]
let Self {
transform,
axis_formatters,
// axis_formatters,
grid_spacers,
clamp_grid,
..
} = self;
let bounds = transform.bounds();
let axis_range = match axis {
0 => bounds.range_x(),
1 => bounds.range_y(),
_ => panic!("Axis {axis} does not exist."),
};
let font_id = TextStyle::Body.resolve(ui.style());
let iaxis = usize::from(axis);
// Where on the cross-dimension to show the label values
let value_cross = 0.0_f64.clamp(bounds.min[1 - axis], bounds.max[1 - axis]);
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[axis], bounds.max[axis]),
base_step_size: transform.dvalue_dpos()[axis] * MIN_LINE_SPACING_IN_POINTS,
bounds: (bounds.min[iaxis], bounds.max[iaxis]),
base_step_size: transform.dvalue_dpos()[iaxis] * MIN_LINE_SPACING_IN_POINTS,
};
let steps = (grid_spacers[axis])(input);
let steps = (grid_spacers[iaxis])(input);
let clamp_range = clamp_grid.then(|| {
let mut tight_bounds = PlotBounds::NOTHING;
@ -1481,25 +1720,27 @@ impl PreparedPlot {
let value_main = step.value;
if let Some(clamp_range) = clamp_range {
if axis == 0 {
if !clamp_range.range_x().contains(&value_main) {
continue;
};
} else {
if !clamp_range.range_y().contains(&value_main) {
continue;
};
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 = if axis == 0 {
PlotPoint::new(value_main, value_cross)
} else {
PlotPoint::new(value_cross, value_main)
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()[axis] * step.step_size).abs() as f32;
let spacing_in_points = (transform.dpos_dvalue()[iaxis] * step.step_size).abs() as f32;
if spacing_in_points > MIN_LINE_SPACING_IN_POINTS as f32 {
let line_strength = remap_clamp(
@ -1508,24 +1749,27 @@ impl PreparedPlot {
0.0..=1.0,
);
let line_color = color_from_contrast(ui, line_strength);
let line_color = color_from_strength(ui, line_strength);
let mut p0 = pos_in_gui;
let mut p1 = pos_in_gui;
p0[1 - axis] = transform.frame().min[1 - axis];
p1[1 - axis] = transform.frame().max[1 - axis];
p0[1 - iaxis] = transform.frame().min[1 - iaxis];
p1[1 - iaxis] = transform.frame().max[1 - iaxis];
if let Some(clamp_range) = clamp_range {
if axis == 0 {
p0.y = transform.position_from_point_y(clamp_range.min[1]);
p1.y = transform.position_from_point_y(clamp_range.max[1]);
} else {
p0.x = transform.position_from_point_x(clamp_range.min[0]);
p1.x = transform.position_from_point_x(clamp_range.max[0]);
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 sharp_grid_lines {
if self.sharp_grid_lines {
// Round to avoid aliasing
p0 = ui.ctx().round_pos_to_pixels(p0);
p1 = ui.ctx().round_pos_to_pixels(p1);
@ -1536,47 +1780,6 @@ impl PreparedPlot {
line_strength,
));
}
const MIN_TEXT_SPACING: f32 = 40.0;
if spacing_in_points > MIN_TEXT_SPACING {
let text_strength =
remap_clamp(spacing_in_points, MIN_TEXT_SPACING..=150.0, 0.0..=1.0);
let color = color_from_contrast(ui, text_strength);
let text: String = if let Some(formatter) = axis_formatters[axis].as_deref() {
formatter(value_main, &axis_range)
} else {
emath::round_to_decimals(value_main, 5).to_string() // hack
};
// Skip origin label for y-axis if x-axis is already showing it (otherwise displayed twice)
let skip_origin_y = axis == 1 && other_axis_shown && value_main == 0.0;
// Custom formatters can return empty string to signal "no label at this resolution"
if !text.is_empty() && !skip_origin_y {
let galley = ui.painter().layout_no_wrap(text, font_id.clone(), color);
let mut text_pos = pos_in_gui + vec2(1.0, -galley.size().y);
// Make sure we see the labels, even if the axis is off-screen:
text_pos[1 - axis] = text_pos[1 - axis]
.at_most(transform.frame().max[1 - axis] - galley.size()[1 - axis] - 2.0)
.at_least(transform.frame().min[1 - axis] + 1.0);
shapes.push((Shape::galley(text_pos, galley), text_strength));
}
}
}
fn color_from_contrast(ui: &Ui, contrast: f32) -> Color32 {
let bg = ui.visuals().extreme_bg_color;
let fg = ui.visuals().widgets.open.fg_stroke.color;
let mix = 0.5 * contrast.sqrt();
Color32::from_rgb(
lerp((bg.r() as f32)..=(fg.r() as f32), mix) as u8,
lerp((bg.g() as f32)..=(fg.g() as f32), mix) as u8,
lerp((bg.b() as f32)..=(fg.b() as f32), mix) as u8,
)
}
}
@ -1682,3 +1885,15 @@ pub fn format_number(number: f64, num_decimals: usize) -> String {
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 bg = ui.visuals().extreme_bg_color;
let fg = ui.visuals().widgets.open.fg_stroke.color;
let mix = 0.5 * strength.sqrt();
Color32::from_rgb(
lerp((bg.r() as f32)..=(fg.r() as f32), mix) as u8,
lerp((bg.g() as f32)..=(fg.g() as f32), mix) as u8,
lerp((bg.b() as f32)..=(fg.b() as f32), mix) as u8,
)
}

View File

@ -1,12 +1,12 @@
use std::f64::consts::TAU;
use std::ops::RangeInclusive;
use egui::plot::{AxisBools, GridInput, GridMark, PlotResponse};
use egui::*;
use plot::{
Arrows, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, HLine,
Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, PlotPoint, PlotPoints, Points, Polygon,
Text, VLine,
use egui::plot::{
Arrows, AxisBools, AxisHints, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter,
Corner, GridInput, GridMark, HLine, Legend, Line, LineStyle, MarkerShape, Plot, PlotImage,
PlotPoint, PlotPoints, PlotResponse, Points, Polygon, Text, VLine,
};
// ----------------------------------------------------------------------------
@ -119,17 +119,9 @@ impl super::View for PlotDemo {
}
}
fn is_approx_zero(val: f64) -> bool {
val.abs() < 1e-6
}
fn is_approx_integer(val: f64) -> bool {
val.fract().abs() < 1e-6
}
// ----------------------------------------------------------------------------
#[derive(PartialEq)]
#[derive(Copy, Clone, PartialEq)]
struct LineDemo {
animate: bool,
time: f64,
@ -138,6 +130,8 @@ struct LineDemo {
square: bool,
proportional: bool,
coordinates: bool,
show_axes: bool,
show_grid: bool,
line_style: LineStyle,
}
@ -151,6 +145,8 @@ impl Default for LineDemo {
square: false,
proportional: true,
coordinates: true,
show_axes: true,
show_grid: true,
line_style: LineStyle::Solid,
}
}
@ -165,9 +161,10 @@ impl LineDemo {
circle_center,
square,
proportional,
line_style,
coordinates,
..
show_axes,
show_grid,
line_style,
} = self;
ui.horizontal(|ui| {
@ -195,6 +192,13 @@ impl LineDemo {
});
});
ui.vertical(|ui| {
ui.checkbox(show_axes, "Show axes");
ui.checkbox(show_grid, "Show grid");
ui.checkbox(coordinates, "Show coordinates on hover")
.on_hover_text("Can take a custom formatting function.");
});
ui.vertical(|ui| {
ui.style_mut().wrap = Some(false);
ui.checkbox(animate, "Animate");
@ -202,8 +206,6 @@ impl LineDemo {
.on_hover_text("Always keep the viewport square.");
ui.checkbox(proportional, "Proportional data axes")
.on_hover_text("Tick are the same size on both axes.");
ui.checkbox(coordinates, "Show coordinates")
.on_hover_text("Can take a custom formatting function.");
ComboBox::from_label("Line style")
.selected_text(line_style.to_string())
@ -268,11 +270,16 @@ impl LineDemo {
impl LineDemo {
fn ui(&mut self, ui: &mut Ui) -> Response {
self.options_ui(ui);
if self.animate {
ui.ctx().request_repaint();
self.time += ui.input(|i| i.unstable_dt).at_most(1.0 / 30.0) as f64;
};
let mut plot = Plot::new("lines_demo").legend(Legend::default());
let mut plot = Plot::new("lines_demo")
.legend(Legend::default())
.y_axis_width(4)
.show_axes(self.show_axes)
.show_grid(self.show_grid);
if self.square {
plot = plot.view_aspect(1.0);
}
@ -429,8 +436,8 @@ impl LegendDemo {
);
ui.end_row();
});
let legend_plot = Plot::new("legend_demo")
.y_axis_width(2)
.legend(config.clone())
.data_aspect(1.0);
legend_plot
@ -523,7 +530,7 @@ impl CustomAxesDemo {
100.0 * y
}
let x_fmt = |x, _range: &RangeInclusive<f64>| {
let x_fmt = |x, _digits, _range: &RangeInclusive<f64>| {
if x < 0.0 * MINS_PER_DAY || x >= 5.0 * MINS_PER_DAY {
// No labels outside value bounds
String::new()
@ -536,7 +543,7 @@ impl CustomAxesDemo {
}
};
let y_fmt = |y, _range: &RangeInclusive<f64>| {
let y_fmt = |y, _digits, _range: &RangeInclusive<f64>| {
// Display only integer percentages
if !is_approx_zero(y) && is_approx_integer(100.0 * y) {
format!("{:.0}%", percent(y))
@ -557,10 +564,23 @@ impl CustomAxesDemo {
ui.label("Zoom in on the X-axis to see hours and minutes");
let x_axes = vec![
AxisHints::default().label("Time").formatter(x_fmt),
AxisHints::default().label("Value"),
];
let y_axes = vec![
AxisHints::default()
.label("Percent")
.formatter(y_fmt)
.max_digits(4),
AxisHints::default()
.label("Absolute")
.placement(plot::HPlacement::Right),
];
Plot::new("custom_axes")
.data_aspect(2.0 * MINS_PER_DAY as f32)
.x_axis_formatter(x_fmt)
.y_axis_formatter(y_fmt)
.custom_x_axes(x_axes)
.custom_y_axes(y_axes)
.x_grid_spacer(CustomAxesDemo::x_grid)
.label_formatter(label_fmt)
.show(ui, |plot_ui| {
@ -582,15 +602,11 @@ struct LinkedAxesDemo {
impl Default for LinkedAxesDemo {
fn default() -> Self {
let link_x = true;
let link_y = false;
let link_cursor_x = true;
let link_cursor_y = false;
Self {
link_x,
link_y,
link_cursor_x,
link_cursor_y,
link_x: true,
link_y: true,
link_cursor_x: true,
link_cursor_y: true,
}
}
}
@ -642,25 +658,29 @@ impl LinkedAxesDemo {
let link_group_id = ui.id().with("linked_demo");
ui.horizontal(|ui| {
Plot::new("linked_axis_1")
Plot::new("left-top")
.data_aspect(1.0)
.width(250.0)
.height(250.0)
.link_axis(link_group_id, self.link_x, self.link_y)
.link_cursor(link_group_id, self.link_cursor_x, self.link_cursor_y)
.show(ui, LinkedAxesDemo::configure_plot);
Plot::new("linked_axis_2")
Plot::new("right-top")
.data_aspect(2.0)
.width(150.0)
.height(250.0)
.y_axis_width(3)
.y_axis_label("y")
.y_axis_position(plot::HPlacement::Right)
.link_axis(link_group_id, self.link_x, self.link_y)
.link_cursor(link_group_id, self.link_cursor_x, self.link_cursor_y)
.show(ui, LinkedAxesDemo::configure_plot);
});
Plot::new("linked_axis_3")
Plot::new("left-bottom")
.data_aspect(0.5)
.width(250.0)
.height(150.0)
.x_axis_label("x")
.link_axis(link_group_id, self.link_x, self.link_y)
.link_cursor(link_group_id, self.link_cursor_x, self.link_cursor_y)
.show(ui, LinkedAxesDemo::configure_plot)
@ -889,6 +909,7 @@ impl ChartsDemo {
Plot::new("Normal Distribution Demo")
.legend(Legend::default())
.clamp_grid(true)
.y_axis_width(3)
.allow_zoom(self.allow_zoom)
.allow_drag(self.allow_drag)
.show(ui, |plot_ui| plot_ui.bar_chart(chart))
@ -1003,3 +1024,11 @@ impl ChartsDemo {
.response
}
}
fn is_approx_zero(val: f64) -> bool {
val.abs() < 1e-6
}
fn is_approx_integer(val: f64) -> bool {
val.fract().abs() < 1e-6
}

View File

@ -271,6 +271,7 @@ fn example_plot(ui: &mut egui::Ui) -> egui::Response {
let line = Line::new(line_points);
egui::plot::Plot::new("example_plot")
.height(32.0)
.show_axes(false)
.data_aspect(1.0)
.show(ui, |plot_ui| plot_ui.line(line))
.response