Skip to content

Commit

Permalink
egui_plot: customizable spacing of grid and axis label spacing (#3896)
Browse files Browse the repository at this point in the history
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 Jan 26, 2024
1 parent 6b0782c commit 5d0bc2b
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 5d0bc2b

Please sign in to comment.