Skip to content

Commit

Permalink
egui_plot: customizable spacing of grid and axis label spacing (emilk…
Browse files Browse the repository at this point in the history
…#3896)

This lets users specify the spacing of the grid lines and the axis
labels, as well as when these start to fade out.

New:
* `AxisHints::new_x/new_y` (replaces `::default()`)
* `AxisHints::label_spacing`
* `Plot::grid_spacing`
  • Loading branch information
emilk authored and hacknus committed Oct 30, 2024
1 parent 7a37bd9 commit 9aba7a1
Show file tree
Hide file tree
Showing 3 changed files with 229 additions and 180 deletions.
32 changes: 18 additions & 14 deletions crates/egui_demo_lib/src/demo/plot_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,23 +530,27 @@ impl CustomAxesDemo {
100.0 * y
}

let x_fmt = |x, _digits, _range: &RangeInclusive<f64>| {
if x < 0.0 * MINS_PER_DAY || x >= 5.0 * MINS_PER_DAY {
let time_formatter = |mark: GridMark, _digits, _range: &RangeInclusive<f64>| {
let minutes = mark.value;
if minutes < 0.0 || 5.0 * MINS_PER_DAY <= minutes {
// No labels outside value bounds
String::new()
} else if is_approx_integer(x / MINS_PER_DAY) {
} else if is_approx_integer(minutes / MINS_PER_DAY) {
// Days
format!("Day {}", day(x))
format!("Day {}", day(minutes))
} else {
// Hours and minutes
format!("{h}:{m:02}", h = hour(x), m = minute(x))
format!("{h}:{m:02}", h = hour(minutes), m = minute(minutes))
}
};

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))
let percentage_formatter = |mark: GridMark, _digits, _range: &RangeInclusive<f64>| {
let percent = 100.0 * mark.value;
if is_approx_zero(percent) {
String::new() // skip zero
} else if is_approx_integer(percent) {
// Display only integer percentages
format!("{percent:.0}%")
} else {
String::new()
}
Expand All @@ -565,15 +569,15 @@ 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"),
AxisHints::new_x().label("Time").formatter(time_formatter),
AxisHints::new_x().label("Value"),
];
let y_axes = vec![
AxisHints::default()
AxisHints::new_y()
.label("Percent")
.formatter(y_fmt)
.formatter(percentage_formatter)
.max_digits(4),
AxisHints::default()
AxisHints::new_y()
.label("Absolute")
.placement(egui_plot::HPlacement::Right),
];
Expand Down
257 changes: 147 additions & 110 deletions crates/egui_plot/src/axis.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
use std::{fmt::Debug, ops::RangeInclusive, sync::Arc};

use egui::emath::{remap_clamp, round_to_decimals, Pos2, Rect};
use egui::epaint::{Shape, TextShape};

use crate::{Response, Sense, TextStyle, Ui, WidgetText};
use egui::{
emath::{remap_clamp, round_to_decimals},
epaint::TextShape,
Pos2, Rangef, Rect, Response, Sense, Shape, TextStyle, Ui, WidgetText,
};

use super::{transform::PlotTransform, GridMark};

pub(super) type AxisFormatterFn = dyn Fn(f64, usize, &RangeInclusive<f64>) -> String;
pub(super) type AxisFormatterFn = dyn Fn(GridMark, usize, &RangeInclusive<f64>) -> String;

/// X or Y axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
/// Horizontal X-Axis
X,
X = 0,

/// Vertical Y-axis
Y,
Y = 1,
}

impl From<Axis> for usize {
Expand Down Expand Up @@ -82,42 +83,61 @@ pub struct AxisHints {
pub(super) formatter: Arc<AxisFormatterFn>,
pub(super) digits: usize,
pub(super) placement: Placement,
pub(super) label_spacing: Rangef,
}

// 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 {
impl AxisHints {
/// Initializes a default axis configuration for the X axis.
pub fn new_x() -> Self {
Self::new(Axis::X)
}

/// Initializes a default axis configuration for the X axis.
pub fn new_y() -> Self {
Self::new(Axis::Y)
}

/// Initializes a default axis configuration for the specified axis.
///
/// `label` is empty.
/// `formatter` is default float to string formatter.
/// maximum `digits` on tick label is 5.
fn default() -> Self {
pub fn new(axis: Axis) -> Self {
Self {
label: Default::default(),
formatter: Arc::new(Self::default_formatter),
digits: 5,
placement: Placement::LeftBottom,
label_spacing: match axis {
Axis::X => Rangef::new(60.0, 80.0), // labels can get pretty wide
Axis::Y => Rangef::new(20.0, 30.0), // text isn't very high
},
}
}
}

impl AxisHints {
/// Specify custom formatter for ticks.
///
/// The first parameter of `formatter` is the raw tick value as `f64`.
/// The second parameter is the maximum number of characters that fit into y-labels.
/// The second parameter of `formatter` is the currently shown range on this axis.
pub fn formatter(
mut self,
fmt: impl Fn(f64, usize, &RangeInclusive<f64>) -> String + 'static,
fmt: impl Fn(GridMark, usize, &RangeInclusive<f64>) -> String + 'static,
) -> Self {
self.formatter = Arc::new(fmt);
self
}

fn default_formatter(tick: f64, max_digits: usize, _range: &RangeInclusive<f64>) -> String {
fn default_formatter(
mark: GridMark,
max_digits: usize,
_range: &RangeInclusive<f64>,
) -> String {
let tick = mark.value;

if tick.abs() > 10.0_f64.powf(max_digits as f64) {
let tick_rounded = tick as isize;
return format!("{tick_rounded:+e}");
Expand Down Expand Up @@ -157,6 +177,18 @@ impl AxisHints {
self
}

/// Set the minimum spacing between labels
///
/// When labels get closer together than the given minimum, then they become invisible.
/// When they get further apart than the max, they are at full opacity.
///
/// Labels can never be closer together than the [`crate::Plot::grid_spacing`] setting.
#[inline]
pub fn label_spacing(mut self, range: impl Into<Rangef>) -> Self {
self.label_spacing = range.into();
self
}

pub(super) fn thickness(&self, axis: Axis) -> f32 {
match axis {
Axis::X => {
Expand Down Expand Up @@ -201,114 +233,119 @@ impl AxisWidget {
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,
}
if !ui.is_rect_visible(response.rect) {
return response;
}

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,
}
}
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,
}
},
Placement::RightTop => match axis {
}
},
};

ui.painter()
.add(TextShape::new(text_pos, galley, text_color).with_angle(angle));

// --- add ticks ---
let font_id = TextStyle::Body.resolve(ui.style());
let Some(transform) = self.transform else {
return response;
};

let label_spacing = self.hints.label_spacing;

for step in self.steps.iter() {
let text = (self.hints.formatter)(*step, self.hints.digits, &self.range);
if !text.is_empty() {
let spacing_in_points =
(transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32;

if spacing_in_points <= label_spacing.min {
// Labels are too close together - don't paint them.
continue;
}

// Fade in labels as they get further apart:
let strength = remap_clamp(spacing_in_points, label_spacing, 0.0..=1.0);

let text_color = super::color_from_strength(ui, strength);
let galley = ui
.painter()
.layout_no_wrap(text, font_id.clone(), text_color);

if spacing_in_points < galley.size()[axis as usize] {
continue; // the galley won't fit
}

let text_pos = match axis {
Axis::X => {
let pos = response.rect.center_top();
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: pos.x - galley.size().x / 2.0,
y: pos.y + galley.size().y * 0.25,
x: transform.position_from_point(&projected_point).x
- galley.size().x / 2.0,
y,
}
}
Axis::Y => {
let pos = response.rect.right_center();
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: pos.x - galley.size().y * 1.5,
y: pos.y + galley.size().x / 2.0,
x,
y: transform.position_from_point(&projected_point).y
- galley.size().y / 2.0,
}
}
},
};

ui.painter()
.add(TextShape::new(text_pos, galley, text_color).with_angle(angle));

// --- add ticks ---
let font_id = TextStyle::Body.resolve(ui.style());
let Some(transform) = self.transform else {
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, text_color));
}
ui.painter()
.add(Shape::galley(text_pos, galley, text_color));
}
}

Expand Down
Loading

0 comments on commit 9aba7a1

Please sign in to comment.