Skip to main content

esoc_chart/axis/
tick.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Tick generation: "nice numbers" algorithm for readable axis ticks.
3
4/// Computed tick marks for an axis.
5#[derive(Clone, Debug)]
6pub struct Ticks {
7    /// Tick positions in data space.
8    pub positions: Vec<f64>,
9    /// Formatted tick labels.
10    pub labels: Vec<String>,
11}
12
13/// Generate "nice" tick positions for a given data range.
14///
15/// Based on Paul Heckbert's "Nice Numbers for Graph Labels" algorithm.
16/// Produces approximately `target_count` ticks at round intervals.
17pub fn nice_ticks(min: f64, max: f64, target_count: usize) -> Ticks {
18    if (max - min).abs() < 1e-15 {
19        let label = format_tick(min);
20        return Ticks {
21            positions: vec![min],
22            labels: vec![label],
23        };
24    }
25
26    let target = target_count.max(2) as f64;
27    let range = nice_num(max - min, false);
28    let step = nice_num(range / (target - 1.0), true);
29
30    let graph_min = (min / step).floor() * step;
31    let graph_max = (max / step).ceil() * step;
32
33    let mut positions = Vec::new();
34    let mut v = graph_min;
35    // Safety bound to prevent infinite loops
36    let max_ticks = (target_count + 5) * 2;
37    while v <= graph_max + step * 0.5 && positions.len() < max_ticks {
38        positions.push(v);
39        v += step;
40    }
41
42    let labels = positions.iter().map(|&v| format_tick(v)).collect();
43
44    Ticks { positions, labels }
45}
46
47/// Generate nice tick positions for logarithmic axes.
48pub fn nice_ticks_log(min: f64, max: f64) -> Ticks {
49    let log_min = min.max(1e-15).log10().floor() as i32;
50    let log_max = max.max(1e-15).log10().ceil() as i32;
51
52    let mut positions = Vec::new();
53    for exp in log_min..=log_max {
54        positions.push(10.0_f64.powi(exp));
55    }
56
57    let labels = positions.iter().map(|&v| format_tick(v)).collect();
58    Ticks { positions, labels }
59}
60
61/// Compute a "nice" number that is approximately equal to `x`.
62///
63/// If `round` is true, rounds to the nearest nice number.
64/// If false, takes the ceiling.
65fn nice_num(x: f64, round: bool) -> f64 {
66    let exp = x.abs().log10().floor();
67    let frac = x / 10.0_f64.powf(exp);
68
69    let nice_frac = if round {
70        if frac < 1.5 {
71            1.0
72        } else if frac < 3.0 {
73            2.0
74        } else if frac < 7.0 {
75            5.0
76        } else {
77            10.0
78        }
79    } else if frac <= 1.0 {
80        1.0
81    } else if frac <= 2.0 {
82        2.0
83    } else if frac <= 5.0 {
84        5.0
85    } else {
86        10.0
87    };
88
89    nice_frac * 10.0_f64.powf(exp)
90}
91
92/// Format a tick value as a concise label using SI prefixes and comma grouping.
93pub fn format_tick(value: f64) -> String {
94    if value == 0.0 {
95        return "0".to_string();
96    }
97
98    let abs = value.abs();
99    let sign = if value < 0.0 { "-" } else { "" };
100
101    if abs >= 1e9 {
102        let v = value / 1e9;
103        format_si(v, sign, "B")
104    } else if abs >= 1e6 {
105        let v = value / 1e6;
106        format_si(v, sign, "M")
107    } else if abs >= 1e4 {
108        // Comma-grouped integers
109        format_with_commas(value)
110    } else if abs >= 1.0 {
111        if (value - value.round()).abs() < 1e-9 {
112            format!("{}", value as i64)
113        } else {
114            format!("{value:.1}")
115        }
116    } else if abs >= 0.01 {
117        format!("{value:.2}")
118    } else if abs >= 1e-6 {
119        // SI prefix for small numbers
120        if abs >= 1e-3 {
121            let v = value * 1e3;
122            format_si(v, sign, "m")
123        } else {
124            let v = value * 1e6;
125            format_si(v, sign, "\u{00B5}") // µ
126        }
127    } else {
128        format!("{value:.2e}")
129    }
130}
131
132fn format_si(v: f64, sign: &str, suffix: &str) -> String {
133    let abs_v = v.abs();
134    if (abs_v - abs_v.round()).abs() < 0.05 {
135        format!("{sign}{}{suffix}", abs_v.round() as i64)
136    } else {
137        format!("{sign}{abs_v:.1}{suffix}")
138    }
139}
140
141fn format_with_commas(value: f64) -> String {
142    let rounded = value.round() as i64;
143    let s = rounded.abs().to_string();
144    let mut result = String::new();
145    for (i, c) in s.chars().rev().enumerate() {
146        if i > 0 && i % 3 == 0 {
147            result.push(',');
148        }
149        result.push(c);
150    }
151    if rounded < 0 {
152        result.push('-');
153    }
154    result.chars().rev().collect()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_nice_ticks_basic() {
163        let ticks = nice_ticks(0.0, 100.0, 5);
164        assert!(!ticks.positions.is_empty());
165        assert!(ticks.positions[0] <= 0.0);
166        assert!(*ticks.positions.last().unwrap() >= 100.0);
167        // Steps should be round numbers
168        if ticks.positions.len() >= 2 {
169            let step = ticks.positions[1] - ticks.positions[0];
170            assert!(step > 0.0);
171        }
172    }
173
174    #[test]
175    fn test_nice_ticks_small_range() {
176        let ticks = nice_ticks(0.0, 1.0, 5);
177        assert!(ticks.positions.len() >= 2);
178    }
179
180    #[test]
181    fn test_format_tick() {
182        assert_eq!(format_tick(0.0), "0");
183        assert_eq!(format_tick(100.0), "100");
184        assert_eq!(format_tick(2.5), "2.5");
185        // SI prefixes
186        assert_eq!(format_tick(1_000_000.0), "1M");
187        assert_eq!(format_tick(2_500_000.0), "2.5M");
188        assert_eq!(format_tick(1_000_000_000.0), "1B");
189        assert_eq!(format_tick(-3_000_000.0), "-3M");
190        // Comma grouping
191        assert_eq!(format_tick(12_000.0), "12,000");
192        assert_eq!(format_tick(100_000.0), "100,000");
193        // Small numbers
194        assert_eq!(format_tick(0.001), "1m");
195        assert_eq!(format_tick(0.0002), "200\u{00B5}");
196    }
197
198    #[test]
199    fn test_nice_ticks_same_value() {
200        let ticks = nice_ticks(5.0, 5.0, 5);
201        assert_eq!(ticks.positions.len(), 1);
202    }
203}