Skip to main content

model/
tick.rs

1use crate::config::{AxisOptions, AxisScale, Config};
2
3#[derive(Debug, Clone, PartialEq)]
4pub struct TickPlan {
5    pub min: f64,
6    pub max: f64,
7    pub major_spacing: f64,
8    pub minor_count: usize,
9}
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum TickError {
13    InvalidRange,
14    NonPositiveLog,
15    ZeroTargetCount,
16}
17
18fn nice_num(range: f64, round: bool) -> f64 {
19    let exp = range.log10().floor();
20    let frac = range / 10f64.powf(exp);
21    let nice_frac = if round {
22        if frac < 1.5 {
23            1.0
24        } else if frac < 3.0 {
25            2.0
26        } else if frac < 7.0 {
27            5.0
28        } else {
29            10.0
30        }
31    } else {
32        if frac <= 1.0 {
33            1.0
34        } else if frac <= 2.0 {
35            2.0
36        } else if frac <= 5.0 {
37            5.0
38        } else {
39            10.0
40        }
41    };
42    nice_frac * 10f64.powf(exp)
43}
44
45fn finite_nice_value(value: f64, fallback: f64) -> f64 {
46    if value.is_finite() && value > 0.0 {
47        value
48    } else {
49        fallback
50    }
51}
52
53fn checked_plan(
54    min: f64,
55    max: f64,
56    major_spacing: f64,
57    minor_count: usize,
58    data_min: f64,
59    data_max: f64,
60    logarithmic: bool,
61) -> Result<TickPlan, TickError> {
62    if !(min.is_finite()
63        && max.is_finite()
64        && major_spacing.is_finite()
65        && major_spacing > 0.0
66        && min < max
67        && min <= data_min
68        && max >= data_max
69        && (!logarithmic || min > 0.0))
70    {
71        return Err(TickError::InvalidRange);
72    }
73    Ok(TickPlan {
74        min,
75        max,
76        major_spacing,
77        minor_count,
78    })
79}
80
81fn compute_linear(
82    data_min: f64,
83    data_max: f64,
84    target_count: usize,
85) -> Result<TickPlan, TickError> {
86    let span = data_max - data_min;
87    if !(span.is_finite() && span > 0.0) {
88        return Err(TickError::InvalidRange);
89    }
90    let range = finite_nice_value(nice_num(span, false), span);
91    let divisor = ((target_count as f64) - 1.0).max(1.0);
92    let raw_step = finite_nice_value(range / divisor, range);
93    let step = finite_nice_value(nice_num(raw_step, true), raw_step);
94    let candidate_min = (data_min / step).floor() * step;
95    let candidate_max = (data_max / step).ceil() * step;
96    let nice_min = if candidate_min.is_finite() && candidate_min <= data_min {
97        candidate_min
98    } else {
99        data_min
100    };
101    let nice_max = if candidate_max.is_finite() && candidate_max >= data_max {
102        candidate_max
103    } else {
104        data_max
105    };
106    checked_plan(nice_min, nice_max, step, 4, data_min, data_max, false)
107}
108
109fn compute_log(data_min: f64, data_max: f64, target_count: usize) -> Result<TickPlan, TickError> {
110    let log_min = data_min.log10().floor();
111    let log_max = data_max.log10().ceil();
112    let decades = log_max - log_min;
113    if !(log_min.is_finite() && log_max.is_finite() && decades.is_finite() && decades > 0.0) {
114        return Err(TickError::InvalidRange);
115    }
116    let divisor = ((target_count as f64) - 1.0).max(1.0);
117    let decade_step = (decades / divisor).ceil().max(1.0);
118    let candidate_min = 10f64.powf(log_min);
119    let candidate_max = 10f64.powf(log_max);
120    let nice_min = if candidate_min.is_finite() && candidate_min > 0.0 {
121        candidate_min
122    } else {
123        data_min
124    };
125    let nice_max = if candidate_max.is_finite() {
126        candidate_max
127    } else {
128        data_max
129    };
130    checked_plan(nice_min, nice_max, decade_step, 8, data_min, data_max, true)
131}
132
133impl AxisOptions {
134    pub fn compute_nice_ticks(
135        scale: AxisScale,
136        data_min: f64,
137        data_max: f64,
138        target_count: usize,
139    ) -> Result<TickPlan, TickError> {
140        if target_count == 0 {
141            return Err(TickError::ZeroTargetCount);
142        }
143        if !(data_min.is_finite() && data_max.is_finite() && data_min < data_max) {
144            return Err(TickError::InvalidRange);
145        }
146        if scale == AxisScale::Logarithmic && data_min <= 0.0 {
147            return Err(TickError::NonPositiveLog);
148        }
149        match scale {
150            AxisScale::Linear => compute_linear(data_min, data_max, target_count),
151            AxisScale::Logarithmic => compute_log(data_min, data_max, target_count),
152        }
153    }
154
155    pub fn auto_ticks(
156        &mut self,
157        data_min: f64,
158        data_max: f64,
159        target_count: usize,
160    ) -> Result<(), TickError> {
161        let plan =
162            AxisOptions::compute_nice_ticks(self.scale.clone(), data_min, data_max, target_count)?;
163        self.min = plan.min;
164        self.max = plan.max;
165        self.major_spacing = plan.major_spacing;
166        self.minor_count = plan.minor_count;
167        Ok(())
168    }
169}
170
171impl Config {
172    pub fn auto_ticks_all(
173        &mut self,
174        top_x: (f64, f64),
175        bottom_x: (f64, f64),
176        left_y: (f64, f64),
177        right_y: (f64, f64),
178        target_count: usize,
179    ) -> Result<(), TickError> {
180        // Compute all plans first (transactional).
181        let plan_top = AxisOptions::compute_nice_ticks(
182            self.top_x.scale.clone(),
183            top_x.0,
184            top_x.1,
185            target_count,
186        )?;
187        let plan_bottom = AxisOptions::compute_nice_ticks(
188            self.bottom_x.scale.clone(),
189            bottom_x.0,
190            bottom_x.1,
191            target_count,
192        )?;
193        let plan_left = AxisOptions::compute_nice_ticks(
194            self.left_y.scale.clone(),
195            left_y.0,
196            left_y.1,
197            target_count,
198        )?;
199        let plan_right = AxisOptions::compute_nice_ticks(
200            self.right_y.scale.clone(),
201            right_y.0,
202            right_y.1,
203            target_count,
204        )?;
205
206        // Commit.
207        apply_plan(&mut self.top_x, plan_top);
208        apply_plan(&mut self.bottom_x, plan_bottom);
209        apply_plan(&mut self.left_y, plan_left);
210        apply_plan(&mut self.right_y, plan_right);
211        Ok(())
212    }
213}
214
215fn apply_plan(axis: &mut AxisOptions, p: TickPlan) {
216    axis.min = p.min;
217    axis.max = p.max;
218    axis.major_spacing = p.major_spacing;
219    axis.minor_count = p.minor_count;
220}
221
222// Invariant tests.
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::default::default_config;
228
229    // plan.min <= data_min, plan.max >= data_max.
230    #[test]
231    fn linear_plan_contains_range() {
232        let p = AxisOptions::compute_nice_ticks(AxisScale::Linear, 0.3, 9.7, 6).unwrap();
233        assert!(p.min <= 0.3);
234        assert!(p.max >= 9.7);
235    }
236
237    // plan.max > plan.min, plan.major_spacing > 0.
238    #[test]
239    fn linear_plan_non_degenerate() {
240        let p = AxisOptions::compute_nice_ticks(AxisScale::Linear, -50.0, 50.0, 5).unwrap();
241        assert!(p.max > p.min);
242        assert!(p.major_spacing > 0.0);
243    }
244
245    #[test]
246    fn log_plan_contains_range() {
247        let p = AxisOptions::compute_nice_ticks(AxisScale::Logarithmic, 0.003, 250.0, 5).unwrap();
248        assert!(p.min <= 0.003);
249        assert!(p.max >= 250.0);
250        assert!(p.max > p.min);
251        assert!(p.major_spacing > 0.0);
252    }
253
254    #[test]
255    fn log_rejects_non_positive() {
256        let e = AxisOptions::compute_nice_ticks(AxisScale::Logarithmic, 0.0, 100.0, 5).unwrap_err();
257        assert_eq!(e, TickError::NonPositiveLog);
258    }
259
260    #[test]
261    fn rejects_invalid_range() {
262        let e = AxisOptions::compute_nice_ticks(AxisScale::Linear, 5.0, 5.0, 5).unwrap_err();
263        assert_eq!(e, TickError::InvalidRange);
264    }
265
266    #[test]
267    fn rejects_zero_target_count() {
268        let e = AxisOptions::compute_nice_ticks(AxisScale::Linear, 0.0, 10.0, 0).unwrap_err();
269        assert_eq!(e, TickError::ZeroTargetCount);
270    }
271
272    #[test]
273    fn rejects_every_non_finite_bound_and_reversed_ranges() {
274        for (data_min, data_max) in [
275            (f64::NAN, 1.0),
276            (1.0, f64::NAN),
277            (f64::NEG_INFINITY, 1.0),
278            (1.0, f64::NEG_INFINITY),
279            (f64::INFINITY, 1.0),
280            (1.0, f64::INFINITY),
281            (2.0, 1.0),
282        ] {
283            assert_eq!(
284                AxisOptions::compute_nice_ticks(AxisScale::Logarithmic, data_min, data_max, 5),
285                Err(TickError::InvalidRange),
286                "range ({data_min:?}, {data_max:?})"
287            );
288        }
289    }
290
291    #[test]
292    fn target_count_then_range_then_log_domain_define_error_priority() {
293        assert_eq!(
294            AxisOptions::compute_nice_ticks(AxisScale::Logarithmic, f64::NAN, 0.0, 0),
295            Err(TickError::ZeroTargetCount)
296        );
297        assert_eq!(
298            AxisOptions::compute_nice_ticks(AxisScale::Logarithmic, -1.0, -2.0, 5),
299            Err(TickError::InvalidRange)
300        );
301        assert_eq!(
302            AxisOptions::compute_nice_ticks(AxisScale::Logarithmic, -1.0, 2.0, 5),
303            Err(TickError::NonPositiveLog)
304        );
305    }
306
307    #[test]
308    fn extreme_finite_ranges_produce_finite_ordered_plans_or_fail_cleanly() {
309        for (scale, data_min, data_max) in [
310            (AxisScale::Linear, 0.0, f64::from_bits(1)),
311            (AxisScale::Linear, 0.0, f64::MAX),
312            (AxisScale::Logarithmic, f64::from_bits(1), f64::MAX),
313        ] {
314            let plan = AxisOptions::compute_nice_ticks(scale, data_min, data_max, 5).unwrap();
315            assert!(plan.min.is_finite());
316            assert!(plan.max.is_finite());
317            assert!(plan.major_spacing.is_finite());
318            assert!(plan.min < plan.max);
319            assert!(plan.min <= data_min);
320            assert!(plan.max >= data_max);
321        }
322
323        assert_eq!(
324            AxisOptions::compute_nice_ticks(AxisScale::Linear, -f64::MAX, f64::MAX, 5),
325            Err(TickError::InvalidRange)
326        );
327    }
328
329    // auto_ticks Err must leave self unchanged.
330    #[test]
331    fn auto_ticks_err_preserves_state() {
332        let mut cfg = default_config();
333        let before = cfg.top_x.clone();
334        let r = cfg.top_x.auto_ticks(-f64::MAX, f64::MAX, 5);
335        assert!(r.is_err());
336        assert_eq!(cfg.top_x, before);
337    }
338
339    // auto_ticks_all Err must leave all four axes unchanged.
340    #[test]
341    fn auto_ticks_all_err_preserves_all() {
342        let mut cfg = default_config();
343        let before = cfg.clone();
344        let r = cfg.auto_ticks_all((0.0, 10.0), (0.0, 10.0), (f64::NAN, 5.0), (0.0, 10.0), 5);
345        assert!(r.is_err());
346        assert_eq!(cfg, before);
347    }
348
349    // compute_nice_ticks is pure.
350    #[test]
351    fn compute_nice_ticks_pure() {
352        let a = AxisOptions::compute_nice_ticks(AxisScale::Linear, 0.0, 100.0, 6).unwrap();
353        let b = AxisOptions::compute_nice_ticks(AxisScale::Linear, 0.0, 100.0, 6).unwrap();
354        assert_eq!(a, b);
355    }
356}