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:
parent
a3ae81cadb
commit
dbe55ba46a
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue