mirror of
https://github.com/emilk/egui.git
synced 2026-08-30 13:20:05 -04:00
egui_plot: customizable spacing of grid and axis label spacing (#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`
This commit is contained in:
@@ -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 {
|
||||
@@ -82,28 +83,41 @@ 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`.
|
||||
@@ -111,13 +125,19 @@ impl AxisHints {
|
||||
/// 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}");
|
||||
@@ -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 => {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
},
|
||||
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,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user