maelstrom_plot/
axis.rs

1use super::{transform::PlotTransform, GridMark};
2use egui::{
3    emath::{remap_clamp, round_to_decimals, Pos2, Rect},
4    epaint::{Shape, TextShape},
5    Response, Sense, TextStyle, Ui, WidgetText,
6};
7use std::{fmt::Debug, ops::RangeInclusive, sync::Arc};
8
9pub(super) type AxisFormatterFn = dyn Fn(f64, usize, &RangeInclusive<f64>) -> String;
10
11/// X or Y axis.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Axis {
14    /// Horizontal X-Axis
15    X,
16
17    /// Vertical Y-axis
18    Y,
19}
20
21impl From<Axis> for usize {
22    #[inline]
23    fn from(value: Axis) -> Self {
24        match value {
25            Axis::X => 0,
26            Axis::Y => 1,
27        }
28    }
29}
30
31/// Placement of the horizontal X-Axis.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum VPlacement {
34    Top,
35    Bottom,
36}
37
38/// Placement of the vertical Y-Axis.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum HPlacement {
41    Left,
42    Right,
43}
44
45/// Placement of an axis.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Placement {
48    /// Bottom for X-axis, or left for Y-axis.
49    LeftBottom,
50
51    /// Top for x-axis and right for y-axis.
52    RightTop,
53}
54
55impl From<HPlacement> for Placement {
56    #[inline]
57    fn from(placement: HPlacement) -> Self {
58        match placement {
59            HPlacement::Left => Placement::LeftBottom,
60            HPlacement::Right => Placement::RightTop,
61        }
62    }
63}
64
65impl From<VPlacement> for Placement {
66    #[inline]
67    fn from(placement: VPlacement) -> Self {
68        match placement {
69            VPlacement::Top => Placement::RightTop,
70            VPlacement::Bottom => Placement::LeftBottom,
71        }
72    }
73}
74
75/// Axis configuration.
76///
77/// Used to configure axis label and ticks.
78#[derive(Clone)]
79pub struct AxisHints {
80    pub(super) label: WidgetText,
81    pub(super) formatter: Arc<AxisFormatterFn>,
82    pub(super) digits: usize,
83    pub(super) placement: Placement,
84}
85
86// TODO: this just a guess. It might cease to work if a user changes font size.
87const LINE_HEIGHT: f32 = 12.0;
88
89impl Default for AxisHints {
90    /// Initializes a default axis configuration for the specified axis.
91    ///
92    /// `label` is empty.
93    /// `formatter` is default float to string formatter.
94    /// maximum `digits` on tick label is 5.
95    fn default() -> Self {
96        Self {
97            label: Default::default(),
98            formatter: Arc::new(Self::default_formatter),
99            digits: 5,
100            placement: Placement::LeftBottom,
101        }
102    }
103}
104
105impl AxisHints {
106    /// Specify custom formatter for ticks.
107    ///
108    /// The first parameter of `formatter` is the raw tick value as `f64`.
109    /// The second parameter is the maximum number of characters that fit into y-labels.
110    /// The second parameter of `formatter` is the currently shown range on this axis.
111    pub fn formatter(
112        mut self,
113        fmt: impl Fn(f64, usize, &RangeInclusive<f64>) -> String + 'static,
114    ) -> Self {
115        self.formatter = Arc::new(fmt);
116        self
117    }
118
119    fn default_formatter(tick: f64, max_digits: usize, _range: &RangeInclusive<f64>) -> String {
120        if tick.abs() > 10.0_f64.powf(max_digits as f64) {
121            let tick_rounded = tick as isize;
122            return format!("{tick_rounded:+e}");
123        }
124        let tick_rounded = round_to_decimals(tick, max_digits);
125        if tick.abs() < 10.0_f64.powf(-(max_digits as f64)) && tick != 0.0 {
126            return format!("{tick_rounded:+e}");
127        }
128        tick_rounded.to_string()
129    }
130
131    /// Specify axis label.
132    ///
133    /// The default is 'x' for x-axes and 'y' for y-axes.
134    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
135        self.label = label.into();
136        self
137    }
138
139    /// Specify maximum number of digits for ticks.
140    ///
141    /// This is considered by the default tick formatter and affects the width of the y-axis
142    pub fn max_digits(mut self, digits: usize) -> Self {
143        self.digits = digits;
144        self
145    }
146
147    /// Specify the placement of the axis.
148    ///
149    /// For X-axis, use [`VPlacement`].
150    /// For Y-axis, use [`HPlacement`].
151    pub fn placement(mut self, placement: impl Into<Placement>) -> Self {
152        self.placement = placement.into();
153        self
154    }
155
156    pub(super) fn thickness(&self, axis: Axis) -> f32 {
157        match axis {
158            Axis::X => {
159                if self.label.is_empty() {
160                    1.0 * LINE_HEIGHT
161                } else {
162                    3.0 * LINE_HEIGHT
163                }
164            }
165            Axis::Y => {
166                if self.label.is_empty() {
167                    (self.digits as f32) * LINE_HEIGHT
168                } else {
169                    (self.digits as f32 + 1.0) * LINE_HEIGHT
170                }
171            }
172        }
173    }
174}
175
176#[derive(Clone)]
177pub(super) struct AxisWidget {
178    pub(super) range: RangeInclusive<f64>,
179    pub(super) hints: AxisHints,
180    pub(super) rect: Rect,
181    pub(super) transform: Option<PlotTransform>,
182    pub(super) steps: Arc<Vec<GridMark>>,
183}
184
185impl AxisWidget {
186    /// if `rect` as width or height == 0, is will be automatically calculated from ticks and text.
187    pub(super) fn new(hints: AxisHints, rect: Rect) -> Self {
188        Self {
189            range: (0.0..=0.0),
190            hints,
191            rect,
192            transform: None,
193            steps: Default::default(),
194        }
195    }
196
197    pub fn ui(self, ui: &mut Ui, axis: Axis) -> Response {
198        let response = ui.allocate_rect(self.rect, Sense::hover());
199
200        if ui.is_rect_visible(response.rect) {
201            let visuals = ui.style().visuals.clone();
202            let text = self.hints.label;
203            let galley = text.into_galley(ui, Some(false), f32::INFINITY, TextStyle::Body);
204            let text_color = visuals
205                .override_text_color
206                .unwrap_or_else(|| ui.visuals().text_color());
207            let angle: f32 = match axis {
208                Axis::X => 0.0,
209                Axis::Y => -std::f32::consts::TAU * 0.25,
210            };
211            // select text_pos and angle depending on placement and orientation of widget
212            let text_pos = match self.hints.placement {
213                Placement::LeftBottom => match axis {
214                    Axis::X => {
215                        let pos = response.rect.center_bottom();
216                        Pos2 {
217                            x: pos.x - galley.size().x / 2.0,
218                            y: pos.y - galley.size().y * 1.25,
219                        }
220                    }
221                    Axis::Y => {
222                        let pos = response.rect.left_center();
223                        Pos2 {
224                            x: pos.x,
225                            y: pos.y + galley.size().x / 2.0,
226                        }
227                    }
228                },
229                Placement::RightTop => match axis {
230                    Axis::X => {
231                        let pos = response.rect.center_top();
232                        Pos2 {
233                            x: pos.x - galley.size().x / 2.0,
234                            y: pos.y + galley.size().y * 0.25,
235                        }
236                    }
237                    Axis::Y => {
238                        let pos = response.rect.right_center();
239                        Pos2 {
240                            x: pos.x - galley.size().y * 1.5,
241                            y: pos.y + galley.size().x / 2.0,
242                        }
243                    }
244                },
245            };
246            ui.painter()
247                .add(TextShape::new(text_pos, galley, text_color).with_angle(angle));
248
249            // --- add ticks ---
250            let font_id = TextStyle::Body.resolve(ui.style());
251            let Some(transform) = self.transform else {
252                return response;
253            };
254
255            for step in self.steps.iter() {
256                let text = (self.hints.formatter)(step.value, self.hints.digits, &self.range);
257                if !text.is_empty() {
258                    const MIN_TEXT_SPACING: f32 = 20.0;
259                    const FULL_CONTRAST_SPACING: f32 = 40.0;
260                    let spacing_in_points =
261                        (transform.dpos_dvalue()[usize::from(axis)] * step.step_size).abs() as f32;
262
263                    if spacing_in_points <= MIN_TEXT_SPACING {
264                        continue;
265                    }
266                    let line_strength = remap_clamp(
267                        spacing_in_points,
268                        MIN_TEXT_SPACING..=FULL_CONTRAST_SPACING,
269                        0.0..=1.0,
270                    );
271
272                    let line_color = super::color_from_strength(ui, line_strength);
273                    let galley = ui
274                        .painter()
275                        .layout_no_wrap(text, font_id.clone(), line_color);
276
277                    let text_pos = match axis {
278                        Axis::X => {
279                            let y = match self.hints.placement {
280                                Placement::LeftBottom => self.rect.min.y,
281                                Placement::RightTop => self.rect.max.y - galley.size().y,
282                            };
283                            let projected_point = super::PlotPoint::new(step.value, 0.0);
284                            Pos2 {
285                                x: transform.position_from_point(&projected_point).x
286                                    - galley.size().x / 2.0,
287                                y,
288                            }
289                        }
290                        Axis::Y => {
291                            let x = match self.hints.placement {
292                                Placement::LeftBottom => self.rect.max.x - galley.size().x,
293                                Placement::RightTop => self.rect.min.x,
294                            };
295                            let projected_point = super::PlotPoint::new(0.0, step.value);
296                            Pos2 {
297                                x,
298                                y: transform.position_from_point(&projected_point).y
299                                    - galley.size().y / 2.0,
300                            }
301                        }
302                    };
303
304                    ui.painter()
305                        .add(Shape::galley(text_pos, galley, text_color));
306                }
307            }
308        }
309
310        response
311    }
312}