Skip to main content

ggplot_rs/scale/
util.rs

1/// Upper bound on label-start candidates per (q, j, k, z) in [`extended_breaks`].
2/// Real inputs need a handful; only precision-degenerate ranges get near this.
3const MAX_START_CANDIDATES: f64 = 10_000.0;
4
5/// True when `[dmin, dmax]` is too narrow, relative to its magnitude, to place
6/// distinct ticks on (floating-point noise around a constant value).
7pub fn is_degenerate_range(dmin: f64, dmax: f64) -> bool {
8    let range = (dmax - dmin).abs();
9    range < f64::EPSILON || range <= 1e-10 * dmin.abs().max(dmax.abs())
10}
11
12/// Evenly spaced break values `start, start+step, …` up to `end` (inclusive,
13/// with a small tolerance). Index-driven and capped, so it terminates even when
14/// `step` is below the floating-point resolution of `start` (where repeated
15/// `v += step` would never advance).
16pub fn stepped_breaks(start: f64, end: f64, step: f64) -> Vec<f64> {
17    const MAX_BREAKS: f64 = 1_000.0;
18    if !(start.is_finite() && end.is_finite() && step.is_finite()) || step <= 0.0 {
19        return vec![];
20    }
21    let n = ((end + step * 0.001 - start) / step).floor();
22    if !(0.0..=MAX_BREAKS).contains(&n) {
23        return vec![];
24    }
25    (0..=n as usize).map(|i| start + i as f64 * step).collect()
26}
27
28/// Extended-Wilkinson tick locations (Talbot, Lin & Hanrahan 2010), matching R's
29/// `scales::extended_breaks()` / `labeling::extended()`. Returns "nice" break
30/// values covering `[dmin, dmax]` with roughly `m` labels.
31pub fn extended_breaks(dmin: f64, dmax: f64, m: usize) -> Vec<f64> {
32    if !(dmin.is_finite() && dmax.is_finite()) || dmax <= dmin || m < 2 {
33        return vec![];
34    }
35    // A range that is negligible relative to the magnitude of the data (e.g.
36    // 1-ulp float noise from SUM/AVG) has no meaningful breaks, and the search
37    // below would need more than 2^53 candidate starts to cover it.
38    if is_degenerate_range(dmin, dmax) {
39        return vec![];
40    }
41    // Preferred step mantissas and score weights (simplicity, coverage, density,
42    // legibility) — the paper's defaults, as in the `labeling` package.
43    const Q: [f64; 6] = [1.0, 5.0, 2.0, 2.5, 4.0, 3.0];
44    const W: [f64; 4] = [0.25, 0.2, 0.5, 0.05];
45    let m = m as f64;
46
47    let simplicity = |qi: usize, j: f64, lmin: f64, lmax: f64, step: f64| {
48        let eps = 1e-10;
49        let modulo = lmin.rem_euclid(step);
50        let v = if (modulo < eps || step - modulo < eps) && lmin <= 0.0 && lmax >= 0.0 {
51            1.0
52        } else {
53            0.0
54        };
55        1.0 - qi as f64 / (Q.len() as f64 - 1.0) - j + v
56    };
57    let simplicity_max = |qi: usize, j: f64| 1.0 - qi as f64 / (Q.len() as f64 - 1.0) - j + 1.0;
58    let coverage = |lmin: f64, lmax: f64| {
59        let r = dmax - dmin;
60        1.0 - 0.5 * ((dmax - lmax).powi(2) + (dmin - lmin).powi(2)) / (0.1 * r).powi(2)
61    };
62    let coverage_max = |span: f64| {
63        let r = dmax - dmin;
64        if span > r {
65            let half = (span - r) / 2.0;
66            1.0 - 0.5 * (half * half + half * half) / (0.1 * r).powi(2)
67        } else {
68            1.0
69        }
70    };
71    let density = |k: f64, lmin: f64, lmax: f64| {
72        let r = (k - 1.0) / (lmax - lmin);
73        let rt = (m - 1.0) / (lmax.max(dmax) - lmin.min(dmin));
74        2.0 - (r / rt).max(rt / r)
75    };
76    let density_max = |k: f64| {
77        if k >= m {
78            2.0 - (k - 1.0) / (m - 1.0)
79        } else {
80            1.0
81        }
82    };
83
84    let mut best_score = -2.0;
85    let mut best = (dmin, dmax, (dmax - dmin) / (m - 1.0));
86
87    for j_i in 1..=4u32 {
88        let j = j_i as f64;
89        for (qi, &q) in Q.iter().enumerate() {
90            let sm = simplicity_max(qi, j);
91            if W[0] * sm + W[1] + W[2] + W[3] < best_score {
92                break;
93            }
94            for k_i in 2..=(2.0 * m + 4.0) as usize {
95                let k = k_i as f64;
96                let dm = density_max(k);
97                if W[0] * sm + W[1] + W[2] * dm + W[3] < best_score {
98                    break;
99                }
100                let delta = (dmax - dmin) / (k + 1.0) / j / q;
101                let z0 = delta.log10().ceil() as i32;
102                for z in z0..(z0 + 6) {
103                    let step = j * q * 10f64.powi(z);
104                    let cm = coverage_max(step * (k - 1.0));
105                    if W[0] * sm + W[1] * cm + W[2] * dm + W[3] < best_score {
106                        break;
107                    }
108                    let min_start = (dmax / step).floor() * j - (k - 1.0) * j;
109                    let max_start = (dmin / step).ceil() * j;
110                    if min_start > max_start {
111                        continue;
112                    }
113                    // Integer-driven so the loop always terminates: once
114                    // `start` exceeds 2^53, `start += 1.0` no longer changes it.
115                    let n_starts = max_start - min_start;
116                    if !n_starts.is_finite() || n_starts > MAX_START_CANDIDATES {
117                        continue;
118                    }
119                    for si in 0..=(n_starts as u64) {
120                        let start = min_start + si as f64;
121                        let lmin = start * step / j;
122                        let lmax = lmin + step * (k - 1.0);
123                        let s = simplicity(qi, j, lmin, lmax, step);
124                        let c = coverage(lmin, lmax);
125                        let g = density(k, lmin, lmax);
126                        let score = W[0] * s + W[1] * c + W[2] * g + W[3];
127                        if score > best_score {
128                            best_score = score;
129                            best = (lmin, lmax, step);
130                        }
131                    }
132                }
133            }
134        }
135    }
136
137    let (lmin, lmax, step) = best;
138    if step <= 0.0 {
139        return vec![];
140    }
141    let n = ((lmax - lmin) / step).round() as usize;
142    (0..=n).map(|i| lmin + i as f64 * step).collect()
143}
144
145/// Round step size to a "nice" value (1, 2, 5, 10, 20, 50, ...).
146pub fn nice_step(raw: f64) -> f64 {
147    let magnitude = 10f64.powf(raw.abs().log10().floor());
148    let fraction = raw / magnitude;
149
150    let nice = if fraction <= 1.5 {
151        1.0
152    } else if fraction <= 3.5 {
153        2.0
154    } else if fraction <= 7.5 {
155        5.0
156    } else {
157        10.0
158    };
159
160    nice * magnitude
161}
162
163/// Format a number nicely, removing trailing zeros.
164pub fn format_number(v: f64) -> String {
165    if v == v.round() && v.abs() < 1e10 {
166        format!("{}", v as i64)
167    } else {
168        let s = format!("{:.2}", v);
169        s.trim_end_matches('0').trim_end_matches('.').to_string()
170    }
171}