embedded_charts/axes/
range.rs

1//! Axis range calculation utilities.
2//!
3//! This module provides functions for calculating intuitive and visually appealing
4//! axis ranges based on data bounds. The goal is to create axis ranges that:
5//! - Start from meaningful values (often 0 for positive data)
6//! - Extend slightly beyond the data to provide visual breathing room
7//! - Use "nice" step sizes that are easy to read (1, 2, 5, 10, etc.)
8//! - Accommodate proper tick placement
9
10#![allow(unused_imports)] // Allow unused micromath imports in test/doctest environments
11
12use crate::data::DataBounds;
13
14/// Configuration for axis range calculation
15#[derive(Debug, Clone, Copy)]
16pub struct RangeCalculationConfig {
17    /// Target number of major ticks (default: 5)
18    pub target_tick_count: usize,
19    /// Threshold for starting from zero (default: 0.3)
20    /// If min value is less than max * threshold, start from 0
21    pub zero_threshold: f32,
22    /// Margin factor for negative data (default: 1.1)
23    pub negative_margin: f32,
24    /// Margin factor for data far from zero (default: 0.9)
25    pub far_from_zero_margin: f32,
26}
27
28impl Default for RangeCalculationConfig {
29    fn default() -> Self {
30        Self {
31            target_tick_count: 5,
32            zero_threshold: 0.3,
33            negative_margin: 1.1,
34            far_from_zero_margin: 0.9,
35        }
36    }
37}
38
39/// Calculate a nice axis range for a single dimension
40///
41/// This function takes a minimum and maximum value and returns a "nice" range
42/// that is visually appealing and mathematically convenient.
43///
44/// # Arguments
45///
46/// * `min` - Minimum data value
47/// * `max` - Maximum data value
48/// * `config` - Configuration for range calculation
49///
50/// # Returns
51///
52/// A tuple of (nice_min, nice_max) representing the calculated axis range
53///
54/// # Examples
55///
56/// ```rust
57/// use embedded_charts::axes::range::{calculate_nice_range, RangeCalculationConfig};
58///
59/// // Data from 8.0 to 35.0 -> Nice range from 0.0 to 40.0
60/// let (min, max) = calculate_nice_range(8.0, 35.0, RangeCalculationConfig::default());
61/// assert_eq!(min, 0.0);
62/// assert_eq!(max, 40.0);
63///
64/// // Data from 0.0 to 9.0 -> Nice range from 0.0 to 10.0
65/// let (min, max) = calculate_nice_range(0.0, 9.0, RangeCalculationConfig::default());
66/// assert_eq!(min, 0.0);
67/// assert_eq!(max, 10.0);
68/// ```
69pub fn calculate_nice_range(min: f32, max: f32, config: RangeCalculationConfig) -> (f32, f32) {
70    if max <= min {
71        // Handle edge case where max <= min
72        if min == max {
73            if min == 0.0 {
74                return (0.0, 1.0);
75            } else if min > 0.0 {
76                return (0.0, min * 1.2);
77            } else {
78                return (min * 1.2, 0.0);
79            }
80        } else {
81            return (max, min); // Swap them
82        }
83    }
84
85    // For positive data, prefer starting from 0 for better context
86    let nice_min = if min >= 0.0 && max > 0.0 {
87        // If minimum is positive and relatively small compared to max, start from 0
88        if min <= max * config.zero_threshold {
89            0.0
90        } else {
91            // Data far from zero - round down to nice value
92            #[cfg(feature = "std")]
93            let magnitude = 10.0_f32.powf((min * config.far_from_zero_margin).log10().floor());
94            #[cfg(all(
95                not(feature = "std"),
96                any(feature = "floating-point", feature = "libm-math")
97            ))]
98            let magnitude = {
99                #[cfg(feature = "floating-point")]
100                {
101                    #[cfg(all(not(feature = "std"), not(test), not(doctest)))]
102                    {
103                        use micromath::F32Ext;
104                        10.0_f32.powf((min * config.far_from_zero_margin).log10().floor())
105                    }
106                    #[cfg(any(feature = "std", test, doctest))]
107                    {
108                        10.0_f32.powf((min * config.far_from_zero_margin).log10().floor())
109                    }
110                }
111                #[cfg(all(feature = "libm-math", not(feature = "floating-point")))]
112                {
113                    libm::powf(
114                        10.0_f32,
115                        libm::floorf(libm::log10f(min * config.far_from_zero_margin)),
116                    )
117                }
118            };
119            #[cfg(not(any(feature = "std", feature = "floating-point", feature = "libm-math")))]
120            let _magnitude = 1.0; // Simplified for fixed-point and integer math
121
122            #[cfg(feature = "std")]
123            let result = (min * config.far_from_zero_margin / magnitude).floor() * magnitude;
124            #[cfg(all(
125                not(feature = "std"),
126                any(feature = "floating-point", feature = "libm-math")
127            ))]
128            let result = {
129                #[cfg(feature = "floating-point")]
130                {
131                    #[cfg(all(not(feature = "std"), not(test), not(doctest)))]
132                    {
133                        use micromath::F32Ext;
134                        (min * config.far_from_zero_margin / magnitude).floor() * magnitude
135                    }
136                    #[cfg(any(feature = "std", test, doctest))]
137                    {
138                        (min * config.far_from_zero_margin / magnitude).floor() * magnitude
139                    }
140                }
141                #[cfg(all(feature = "libm-math", not(feature = "floating-point")))]
142                {
143                    libm::floorf(min * config.far_from_zero_margin / magnitude) * magnitude
144                }
145            };
146            #[cfg(not(any(feature = "std", feature = "floating-point", feature = "libm-math")))]
147            let result = min * config.far_from_zero_margin; // Simplified for fixed-point and integer math
148            result
149        }
150    } else {
151        // Negative data - add margin
152        min * config.negative_margin
153    };
154
155    // Calculate nice maximum that accommodates the next tick beyond data
156    let data_range = max - nice_min;
157    let rough_step = data_range / config.target_tick_count as f32;
158
159    // Round step to nice values
160    #[cfg(feature = "std")]
161    let magnitude = 10.0_f32.powf(rough_step.log10().floor());
162    #[cfg(all(
163        not(feature = "std"),
164        any(feature = "floating-point", feature = "libm-math")
165    ))]
166    let magnitude = {
167        #[cfg(feature = "floating-point")]
168        {
169            #[cfg(all(not(feature = "std"), not(test), not(doctest)))]
170            {
171                use micromath::F32Ext;
172                10.0_f32.powf(rough_step.log10().floor())
173            }
174            #[cfg(any(feature = "std", test, doctest))]
175            {
176                10.0_f32.powf(rough_step.log10().floor())
177            }
178        }
179        #[cfg(all(feature = "libm-math", not(feature = "floating-point")))]
180        {
181            libm::powf(10.0_f32, libm::floorf(libm::log10f(rough_step)))
182        }
183    };
184    #[cfg(not(any(feature = "std", feature = "floating-point", feature = "libm-math")))]
185    let magnitude = 1.0; // Simplified for fixed-point and integer math
186
187    let normalized_step = rough_step / magnitude;
188    let nice_step = if normalized_step <= 1.0 {
189        magnitude
190    } else if normalized_step <= 2.0 {
191        2.0 * magnitude
192    } else if normalized_step <= 5.0 {
193        5.0 * magnitude
194    } else {
195        10.0 * magnitude
196    };
197
198    // Find the first tick at or beyond max
199    #[cfg(feature = "std")]
200    let ticks_from_min = ((max - nice_min) / nice_step).ceil();
201    #[cfg(all(
202        not(feature = "std"),
203        any(feature = "floating-point", feature = "libm-math")
204    ))]
205    let ticks_from_min = {
206        #[cfg(feature = "floating-point")]
207        {
208            #[cfg(all(not(feature = "std"), not(test), not(doctest)))]
209            {
210                use micromath::F32Ext;
211                ((max - nice_min) / nice_step).ceil()
212            }
213            #[cfg(any(feature = "std", test, doctest))]
214            {
215                ((max - nice_min) / nice_step).ceil()
216            }
217        }
218        #[cfg(all(feature = "libm-math", not(feature = "floating-point")))]
219        {
220            libm::ceilf((max - nice_min) / nice_step)
221        }
222    };
223    #[cfg(not(any(feature = "std", feature = "floating-point", feature = "libm-math")))]
224    let ticks_from_min = ((max - nice_min) / nice_step + 0.5) as i32 as f32; // Simple ceiling for fixed-point and integer math
225    let nice_max = nice_min + (ticks_from_min * nice_step);
226
227    (nice_min, nice_max)
228}
229
230/// Calculate nice axis ranges for both X and Y axes from data bounds
231///
232/// This is a convenience function that applies nice range calculation to both
233/// dimensions of a data bounds object.
234///
235/// # Arguments
236///
237/// * `bounds` - Data bounds containing min/max for both X and Y
238/// * `config` - Configuration for range calculation (applied to both axes)
239///
240/// # Returns
241///
242/// A tuple of ((x_min, x_max), (y_min, y_max)) representing nice ranges for both axes
243///
244/// # Examples
245///
246/// ```rust
247/// use embedded_charts::prelude::*;
248/// use embedded_charts::axes::range::{calculate_nice_ranges_from_bounds, RangeCalculationConfig};
249///
250/// # fn main() -> Result<(), embedded_charts::error::DataError> {
251/// let mut series: StaticDataSeries<Point2D, 256> = StaticDataSeries::new();
252/// series.push(Point2D::new(0.0, 8.0))?;
253/// series.push(Point2D::new(9.0, 35.0))?;
254///
255/// let bounds = series.bounds()?;
256/// let ((x_min, x_max), (y_min, y_max)) = calculate_nice_ranges_from_bounds(
257///     &bounds,
258///     RangeCalculationConfig::default()
259/// );
260///
261/// assert_eq!((x_min, x_max), (0.0, 10.0));
262/// assert_eq!((y_min, y_max), (0.0, 40.0));
263/// # Ok(())
264/// # }
265/// ```
266pub fn calculate_nice_ranges_from_bounds<X, Y>(
267    bounds: &DataBounds<X, Y>,
268    config: RangeCalculationConfig,
269) -> ((f32, f32), (f32, f32))
270where
271    X: Into<f32> + Copy + PartialOrd,
272    Y: Into<f32> + Copy + PartialOrd,
273{
274    let x_range = calculate_nice_range(bounds.min_x.into(), bounds.max_x.into(), config);
275    let y_range = calculate_nice_range(bounds.min_y.into(), bounds.max_y.into(), config);
276    (x_range, y_range)
277}
278
279/// Calculate nice axis ranges with separate configurations for X and Y axes
280///
281/// This function allows different configurations for X and Y axes, which can be
282/// useful when the axes have different characteristics (e.g., time on X-axis,
283/// values on Y-axis).
284///
285/// # Arguments
286///
287/// * `bounds` - Data bounds containing min/max for both X and Y
288/// * `x_config` - Configuration for X-axis range calculation
289/// * `y_config` - Configuration for Y-axis range calculation
290///
291/// # Returns
292///
293/// A tuple of ((x_min, x_max), (y_min, y_max)) representing nice ranges for both axes
294pub fn calculate_nice_ranges_separate_config<X, Y>(
295    bounds: &DataBounds<X, Y>,
296    x_config: RangeCalculationConfig,
297    y_config: RangeCalculationConfig,
298) -> ((f32, f32), (f32, f32))
299where
300    X: Into<f32> + Copy + PartialOrd,
301    Y: Into<f32> + Copy + PartialOrd,
302{
303    let x_range = calculate_nice_range(bounds.min_x.into(), bounds.max_x.into(), x_config);
304    let y_range = calculate_nice_range(bounds.min_y.into(), bounds.max_y.into(), y_config);
305    (x_range, y_range)
306}
307
308/// Preset configurations for common use cases
309pub mod presets {
310    use super::RangeCalculationConfig;
311
312    /// Standard configuration - good for most charts
313    pub fn standard() -> RangeCalculationConfig {
314        RangeCalculationConfig::default()
315    }
316
317    /// Tight configuration - minimal padding around data
318    pub fn tight() -> RangeCalculationConfig {
319        RangeCalculationConfig {
320            target_tick_count: 4,
321            zero_threshold: 0.1,
322            negative_margin: 1.05,
323            far_from_zero_margin: 0.95,
324        }
325    }
326
327    /// Loose configuration - more padding around data
328    pub fn loose() -> RangeCalculationConfig {
329        RangeCalculationConfig {
330            target_tick_count: 6,
331            zero_threshold: 0.5,
332            negative_margin: 1.2,
333            far_from_zero_margin: 0.8,
334        }
335    }
336
337    /// Time series configuration - optimized for time-based data
338    pub fn time_series() -> RangeCalculationConfig {
339        RangeCalculationConfig {
340            target_tick_count: 6,
341            zero_threshold: 0.0, // Time rarely starts from 0
342            negative_margin: 1.1,
343            far_from_zero_margin: 0.9,
344        }
345    }
346
347    /// Percentage configuration - optimized for percentage data (0-100)
348    pub fn percentage() -> RangeCalculationConfig {
349        RangeCalculationConfig {
350            target_tick_count: 5,
351            zero_threshold: 1.0,  // Always start from 0 for percentages
352            negative_margin: 1.0, // No negative values expected
353            far_from_zero_margin: 1.0,
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_calculate_nice_range_positive_data() {
364        let config = RangeCalculationConfig::default();
365
366        // Data from 8 to 35 should give 0 to 40
367        let (min, max) = calculate_nice_range(8.0, 35.0, config);
368        assert_eq!(min, 0.0);
369        assert_eq!(max, 40.0);
370
371        // Data from 0 to 9 should give 0 to 10
372        let (min, max) = calculate_nice_range(0.0, 9.0, config);
373        assert_eq!(min, 0.0);
374        assert_eq!(max, 10.0);
375    }
376
377    #[test]
378    fn test_calculate_nice_range_large_positive_data() {
379        let config = RangeCalculationConfig::default();
380
381        // Data from 100 to 150 (min > max * 0.3) should not start from 0
382        let (min, max) = calculate_nice_range(100.0, 150.0, config);
383        assert!(min > 0.0);
384        assert!(min < 100.0);
385        assert!(max >= 150.0);
386    }
387
388    #[test]
389    fn test_calculate_nice_range_negative_data() {
390        let config = RangeCalculationConfig::default();
391
392        // Negative data should have appropriate margins
393        let (min, max) = calculate_nice_range(-50.0, -10.0, config);
394        assert!(min < -50.0);
395        assert!(max >= -10.0);
396    }
397
398    #[test]
399    fn test_calculate_nice_range_edge_cases() {
400        let config = RangeCalculationConfig::default();
401
402        // Equal values
403        let (min, max) = calculate_nice_range(5.0, 5.0, config);
404        assert!(min <= 5.0);
405        assert!(max >= 5.0);
406        assert!(max > min);
407
408        // Zero values
409        let (min, max) = calculate_nice_range(0.0, 0.0, config);
410        assert_eq!(min, 0.0);
411        assert_eq!(max, 1.0);
412    }
413
414    #[test]
415    fn test_preset_configurations() {
416        // Test that presets create different configurations
417        let standard = presets::standard();
418        let tight = presets::tight();
419        let loose = presets::loose();
420
421        assert_eq!(standard.target_tick_count, 5);
422        assert_eq!(tight.target_tick_count, 4);
423        assert_eq!(loose.target_tick_count, 6);
424
425        // Test that they produce different results
426        let (min1, max1) = calculate_nice_range(8.0, 35.0, tight);
427        let (min2, max2) = calculate_nice_range(8.0, 35.0, loose);
428
429        // Loose should generally give larger ranges
430        assert!((max2 - min2) >= (max1 - min1));
431    }
432}