egui_plot/
axis.rs

1use std::{fmt::Debug, ops::RangeInclusive, sync::Arc};
2
3use egui::{
4    Pos2, Rangef, Rect, Response, Sense, TextStyle, TextWrapMode, Ui, Vec2, WidgetText,
5    emath::{Rot2, remap_clamp},
6    epaint::TextShape,
7};
8
9use super::{GridMark, transform::PlotTransform};
10
11// Gap between tick labels and axis label in units of the axis label height
12const AXIS_LABEL_GAP: f32 = 0.25;
13
14pub(super) type AxisFormatterFn<'a> = dyn Fn(GridMark, &RangeInclusive<f64>) -> String + 'a;
15
16/// X or Y axis.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Axis {
19    /// Horizontal X-Axis
20    X = 0,
21
22    /// Vertical Y-axis
23    Y = 1,
24}
25
26impl From<Axis> for usize {
27    #[inline]
28    fn from(value: Axis) -> Self {
29        match value {
30            Axis::X => 0,
31            Axis::Y => 1,
32        }
33    }
34}
35
36/// Placement of the horizontal X-Axis.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum VPlacement {
39    Top,
40    Bottom,
41}
42
43/// Placement of the vertical Y-Axis.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum HPlacement {
46    Left,
47    Right,
48}
49
50/// Placement of an axis.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Placement {
53    /// Bottom for X-axis, or left for Y-axis.
54    LeftBottom,
55
56    /// Top for x-axis and right for y-axis.
57    RightTop,
58}
59
60impl From<HPlacement> for Placement {
61    #[inline]
62    fn from(placement: HPlacement) -> Self {
63        match placement {
64            HPlacement::Left => Self::LeftBottom,
65            HPlacement::Right => Self::RightTop,
66        }
67    }
68}
69
70impl From<Placement> for HPlacement {
71    #[inline]
72    fn from(placement: Placement) -> Self {
73        match placement {
74            Placement::LeftBottom => Self::Left,
75            Placement::RightTop => Self::Right,
76        }
77    }
78}
79
80impl From<VPlacement> for Placement {
81    #[inline]
82    fn from(placement: VPlacement) -> Self {
83        match placement {
84            VPlacement::Top => Self::RightTop,
85            VPlacement::Bottom => Self::LeftBottom,
86        }
87    }
88}
89
90impl From<Placement> for VPlacement {
91    #[inline]
92    fn from(placement: Placement) -> Self {
93        match placement {
94            Placement::LeftBottom => Self::Bottom,
95            Placement::RightTop => Self::Top,
96        }
97    }
98}
99
100/// Axis configuration.
101///
102/// Used to configure axis label and ticks.
103#[derive(Clone)]
104pub struct AxisHints<'a> {
105    pub(super) label: WidgetText,
106    pub(super) formatter: Arc<AxisFormatterFn<'a>>,
107    pub(super) min_thickness: f32,
108    pub(super) placement: Placement,
109    pub(super) label_spacing: Rangef,
110}
111
112impl<'a> AxisHints<'a> {
113    /// Initializes a default axis configuration for the X axis.
114    pub fn new_x() -> Self {
115        Self::new(Axis::X)
116    }
117
118    /// Initializes a default axis configuration for the Y axis.
119    pub fn new_y() -> Self {
120        Self::new(Axis::Y)
121    }
122
123    /// Initializes a default axis configuration for the specified axis.
124    ///
125    /// `label` is empty.
126    /// `formatter` is default float to string formatter.
127    pub fn new(axis: Axis) -> Self {
128        Self {
129            label: Default::default(),
130            formatter: Arc::new(Self::default_formatter),
131            min_thickness: 14.0,
132            placement: Placement::LeftBottom,
133            label_spacing: match axis {
134                Axis::X => Rangef::new(60.0, 80.0), // labels can get pretty wide
135                Axis::Y => Rangef::new(20.0, 30.0), // text isn't very high
136            },
137        }
138    }
139
140    /// Specify custom formatter for ticks.
141    ///
142    /// The first parameter of `formatter` is the raw tick value as `f64`.
143    /// The second parameter of `formatter` is the currently shown range on this axis.
144    pub fn formatter(
145        mut self,
146        fmt: impl Fn(GridMark, &RangeInclusive<f64>) -> String + 'a,
147    ) -> Self {
148        self.formatter = Arc::new(fmt);
149        self
150    }
151
152    fn default_formatter(mark: GridMark, _range: &RangeInclusive<f64>) -> String {
153        // Example: If the step to the next tick is `0.01`, we should use 2 decimals of precision:
154        let num_decimals = -mark.step_size.log10().round() as usize;
155
156        emath::format_with_decimals_in_range(mark.value, num_decimals..=num_decimals)
157    }
158
159    /// Specify axis label.
160    ///
161    /// The default is 'x' for x-axes and 'y' for y-axes.
162    #[inline]
163    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
164        self.label = label.into();
165        self
166    }
167
168    /// Specify minimum thickness of the axis
169    #[inline]
170    pub fn min_thickness(mut self, min_thickness: f32) -> Self {
171        self.min_thickness = min_thickness;
172        self
173    }
174
175    /// Specify maximum number of digits for ticks.
176    #[inline]
177    #[deprecated = "Use `min_thickness` instead"]
178    pub fn max_digits(self, digits: usize) -> Self {
179        self.min_thickness(12.0 * digits as f32)
180    }
181
182    /// Specify the placement of the axis.
183    ///
184    /// For X-axis, use [`VPlacement`].
185    /// For Y-axis, use [`HPlacement`].
186    #[inline]
187    pub fn placement(mut self, placement: impl Into<Placement>) -> Self {
188        self.placement = placement.into();
189        self
190    }
191
192    /// Set the minimum spacing between labels
193    ///
194    /// When labels get closer together than the given minimum, then they become invisible.
195    /// When they get further apart than the max, they are at full opacity.
196    ///
197    /// Labels can never be closer together than the [`crate::Plot::grid_spacing`] setting.
198    #[inline]
199    pub fn label_spacing(mut self, range: impl Into<Rangef>) -> Self {
200        self.label_spacing = range.into();
201        self
202    }
203}
204
205#[derive(Clone)]
206pub(super) struct AxisWidget<'a> {
207    pub range: RangeInclusive<f64>,
208    pub hints: AxisHints<'a>,
209
210    /// The region where we draw the axis labels.
211    pub rect: Rect,
212    pub transform: Option<PlotTransform>,
213    pub steps: Arc<Vec<GridMark>>,
214}
215
216impl<'a> AxisWidget<'a> {
217    /// if `rect` has width or height == 0, it will be automatically calculated from ticks and text.
218    pub fn new(hints: AxisHints<'a>, rect: Rect) -> Self {
219        Self {
220            range: (0.0..=0.0),
221            hints,
222            rect,
223            transform: None,
224            steps: Default::default(),
225        }
226    }
227
228    /// Returns the actual thickness of the axis.
229    pub fn ui(self, ui: &mut Ui, axis: Axis) -> (Response, f32) {
230        let response = ui.allocate_rect(self.rect, Sense::hover());
231
232        if !ui.is_rect_visible(response.rect) {
233            return (response, 0.0);
234        }
235
236        let Some(transform) = self.transform else {
237            return (response, 0.0);
238        };
239        let tick_labels_thickness = self.add_tick_labels(ui, transform, axis);
240
241        if self.hints.label.is_empty() {
242            return (response, tick_labels_thickness);
243        }
244
245        let galley = self.hints.label.into_galley(
246            ui,
247            Some(TextWrapMode::Extend),
248            f32::INFINITY,
249            TextStyle::Body,
250        );
251
252        let text_pos = match self.hints.placement {
253            Placement::LeftBottom => match axis {
254                Axis::X => {
255                    let pos = response.rect.center_bottom();
256                    Pos2 {
257                        x: pos.x - galley.size().x * 0.5,
258                        y: pos.y - galley.size().y * (1.0 + AXIS_LABEL_GAP),
259                    }
260                }
261                Axis::Y => {
262                    let pos = response.rect.left_center();
263                    Pos2 {
264                        x: pos.x - galley.size().y * AXIS_LABEL_GAP,
265                        y: pos.y + galley.size().x * 0.5,
266                    }
267                }
268            },
269            Placement::RightTop => match axis {
270                Axis::X => {
271                    let pos = response.rect.center_top();
272                    Pos2 {
273                        x: pos.x - galley.size().x * 0.5,
274                        y: pos.y + galley.size().y * AXIS_LABEL_GAP,
275                    }
276                }
277                Axis::Y => {
278                    let pos = response.rect.right_center();
279                    Pos2 {
280                        x: pos.x - galley.size().y * (1.0 - AXIS_LABEL_GAP),
281                        y: pos.y + galley.size().x * 0.5,
282                    }
283                }
284            },
285        };
286        let axis_label_thickness = galley.size().y * (1.0 + AXIS_LABEL_GAP);
287        let angle = match axis {
288            Axis::X => 0.0,
289            Axis::Y => -std::f32::consts::FRAC_PI_2,
290        };
291
292        ui.painter()
293            .add(TextShape::new(text_pos, galley, ui.visuals().text_color()).with_angle(angle));
294
295        (response, tick_labels_thickness + axis_label_thickness)
296    }
297
298    /// Add tick labels to the axis. Returns the thickness of the axis.
299    fn add_tick_labels(&self, ui: &Ui, transform: PlotTransform, axis: Axis) -> f32 {
300        let font_id = TextStyle::Body.resolve(ui.style());
301        let label_spacing = self.hints.label_spacing;
302        let mut thickness: f32 = 0.0;
303
304        const SIDE_MARGIN: f32 = 4.0; // Add some margin to both sides of the text on the Y axis.
305        let painter = ui.painter();
306
307        // Add tick labels:
308        for step in self.steps.iter() {
309            let text = (self.hints.formatter)(*step, &self.range);
310            if !text.is_empty() {
311                let spacing_in_points =
312                    (transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32;
313
314                if spacing_in_points <= label_spacing.min {
315                    // Labels are too close together - don't paint them.
316                    continue;
317                }
318
319                // Fade in labels as they get further apart:
320                let strength = remap_clamp(spacing_in_points, label_spacing, 0.0..=1.0);
321
322                let text_color = super::color_from_strength(ui, strength);
323                let galley = painter.layout_no_wrap(text, font_id.clone(), text_color);
324                let galley_size = match axis {
325                    Axis::X => galley.size(),
326                    Axis::Y => galley.size() + 2.0 * SIDE_MARGIN * Vec2::X,
327                };
328
329                if spacing_in_points < galley_size[axis as usize] {
330                    continue; // the galley won't fit (likely too wide on the X axis).
331                }
332
333                match axis {
334                    Axis::X => {
335                        thickness = thickness.max(galley_size.y);
336
337                        let projected_point = super::PlotPoint::new(step.value, 0.0);
338                        let center_x = transform.position_from_point(&projected_point).x;
339                        let y = match VPlacement::from(self.hints.placement) {
340                            VPlacement::Bottom => self.rect.min.y,
341                            VPlacement::Top => self.rect.max.y - galley_size.y,
342                        };
343                        let pos = Pos2::new(center_x - galley_size.x / 2.0, y);
344                        painter.add(TextShape::new(pos, galley, text_color));
345                    }
346                    Axis::Y => {
347                        thickness = thickness.max(galley_size.x);
348
349                        let projected_point = super::PlotPoint::new(0.0, step.value);
350                        let center_y = transform.position_from_point(&projected_point).y;
351
352                        match HPlacement::from(self.hints.placement) {
353                            HPlacement::Left => {
354                                let angle = 0.0; // TODO(emilk): allow users to rotate text
355
356                                if angle == 0.0 {
357                                    let x = self.rect.max.x - galley_size.x + SIDE_MARGIN;
358                                    let pos = Pos2::new(x, center_y - galley_size.y / 2.0);
359                                    painter.add(TextShape::new(pos, galley, text_color));
360                                } else {
361                                    let right =
362                                        Pos2::new(self.rect.max.x, center_y - galley_size.y / 2.0);
363                                    let width = galley_size.x;
364                                    let left =
365                                        right - Rot2::from_angle(angle) * Vec2::new(width, 0.0);
366
367                                    painter.add(
368                                        TextShape::new(left, galley, text_color).with_angle(angle),
369                                    );
370                                }
371                            }
372                            HPlacement::Right => {
373                                let x = self.rect.min.x + SIDE_MARGIN;
374                                let pos = Pos2::new(x, center_y - galley_size.y / 2.0);
375                                painter.add(TextShape::new(pos, galley, text_color));
376                            }
377                        }
378                    }
379                }
380            }
381        }
382        thickness
383    }
384}