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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Axis {
14 X,
16
17 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum VPlacement {
34 Top,
35 Bottom,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum HPlacement {
41 Left,
42 Right,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Placement {
48 LeftBottom,
50
51 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#[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
86const LINE_HEIGHT: f32 = 12.0;
88
89impl Default for AxisHints {
90 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 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 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
135 self.label = label.into();
136 self
137 }
138
139 pub fn max_digits(mut self, digits: usize) -> Self {
143 self.digits = digits;
144 self
145 }
146
147 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 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 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 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}