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 ticks for a log-transformed axis, matching R's `scales::log_breaks()`:
146/// prefers `base^n`; when the range spans too few decades for `m` ticks, mixes
147/// in evenly-spaced sub-multiples of `base` (e.g. `{1,2,5} * 10^n` for base 10);
148/// falls back to [`extended_breaks`] over the *data* range `[dmin, dmax]` when
149/// neither gives enough ticks. `dmin`/`dmax` are in data (pre-transform) space
150/// and must be finite and positive.
151pub fn log_breaks(dmin: f64, dmax: f64, m: usize, base: f64) -> Vec<f64> {
152    let m = m.max(2);
153    if !(dmin.is_finite() && dmax.is_finite())
154        || dmin <= 0.0
155        || dmax <= 0.0
156        || dmax < dmin
157        || base <= 1.0
158    {
159        return extended_breaks(dmin, dmax, m);
160    }
161    if is_degenerate_range(dmin, dmax) {
162        return vec![];
163    }
164
165    let log_min = dmin.log(base);
166    let log_max = dmax.log(base);
167    let min_e = log_min.floor();
168    let max_e = log_max.ceil();
169    if (max_e - min_e).abs() < 1e-9 {
170        return vec![base.powf(min_e)];
171    }
172
173    let want = (m as i64 - 2).max(0) as f64;
174    let exp_seq = |by: f64| -> Vec<f64> {
175        let mut out = Vec::new();
176        let mut e = min_e;
177        while e <= max_e + 1e-9 {
178            out.push(base.powf(e));
179            e += by;
180        }
181        out
182    };
183    let count_relevant = |breaks: &[f64]| -> usize {
184        let lo = base.powf(log_min) * (1.0 - 1e-9);
185        let hi = base.powf(log_max) * (1.0 + 1e-9);
186        breaks.iter().filter(|&&b| b >= lo && b <= hi).count()
187    };
188
189    let mut by = ((max_e - min_e) / m as f64).floor() + 1.0;
190    loop {
191        let breaks = exp_seq(by);
192        if count_relevant(&breaks) as f64 >= want {
193            return breaks;
194        }
195        if by <= 1.0 {
196            break;
197        }
198        by -= 1.0;
199    }
200
201    log_sub_breaks(dmin, dmax, min_e, max_e, m, base)
202}
203
204/// Sub-decade ticks for [`log_breaks`] once whole-decade (or whole-`by`)
205/// steps don't give enough of them — mirrors `scales:::log_sub_breaks()`.
206fn log_sub_breaks(dmin: f64, dmax: f64, min_e: f64, max_e: f64, m: usize, base: f64) -> Vec<f64> {
207    let want = (m as i64 - 2).max(0) as f64;
208    let lo = dmin * (1.0 - 1e-9);
209    let hi = dmax * (1.0 + 1e-9);
210
211    if base <= 2.0 {
212        let mut out = Vec::new();
213        let mut e = min_e;
214        while e <= max_e + 1e-9 {
215            out.push(base.powf(e));
216            e += 1.0;
217        }
218        return out;
219    }
220
221    let exps: Vec<i64> = ((min_e as i64)..=(max_e as i64)).collect();
222    let mut steps: Vec<f64> = vec![1.0];
223    // Integer sub-multiples strictly between 1 and `base` (R's
224    // `seq_len(base)` filtered to `1 < candidate < base`).
225    let mut candidates: Vec<f64> = (2..=(base.floor() as i64))
226        .filter(|&x| (x as f64) < base)
227        .map(|x| x as f64)
228        .collect();
229
230    // Greedily add whichever remaining candidate leaves the largest gap
231    // (in log space) between consecutive steps, same as the R reference.
232    let delta = |x: f64, steps: &[f64]| -> f64 {
233        let mut vals: Vec<f64> = steps.to_vec();
234        vals.push(x);
235        vals.push(base);
236        vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
237        vals.windows(2)
238            .map(|w| w[1].log(base) - w[0].log(base))
239            .fold(f64::INFINITY, f64::min)
240    };
241
242    let mut breaks: Vec<f64> = Vec::new();
243    let mut relevant = 0usize;
244    while !candidates.is_empty() {
245        let (best_i, _) = candidates
246            .iter()
247            .enumerate()
248            .map(|(i, &c)| (i, delta(c, &steps)))
249            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
250            .unwrap();
251        steps.push(candidates.remove(best_i));
252        breaks = exps
253            .iter()
254            .flat_map(|&e| steps.iter().map(move |&s| base.powf(e as f64) * s))
255            .collect();
256        relevant = breaks.iter().filter(|&&b| b >= lo && b <= hi).count();
257        if relevant as f64 >= want {
258            break;
259        }
260    }
261
262    if relevant as f64 >= want && !breaks.is_empty() {
263        breaks.sort_by(|a, b| a.partial_cmp(b).unwrap());
264        let first_in = breaks.iter().position(|&b| b >= lo);
265        let last_in = breaks.iter().rposition(|&b| b <= hi);
266        match (first_in, last_in) {
267            (Some(fi), Some(li)) => {
268                let start = fi.saturating_sub(1);
269                let end = (li + 1).min(breaks.len() - 1);
270                breaks[start..=end].to_vec()
271            }
272            _ => breaks,
273        }
274    } else {
275        extended_breaks(dmin, dmax, m)
276    }
277}
278
279/// Round step size to a "nice" value (1, 2, 5, 10, 20, 50, ...).
280pub fn nice_step(raw: f64) -> f64 {
281    let magnitude = 10f64.powf(raw.abs().log10().floor());
282    let fraction = raw / magnitude;
283
284    let nice = if fraction <= 1.5 {
285        1.0
286    } else if fraction <= 3.5 {
287        2.0
288    } else if fraction <= 7.5 {
289        5.0
290    } else {
291        10.0
292    };
293
294    nice * magnitude
295}
296
297/// Format a number nicely, removing trailing zeros.
298/// Format a set of break values together: like [`format_number`] (integers
299/// verbatim, otherwise up to 2 decimals, trailing zeros trimmed), but with as
300/// many extra decimals as needed so that distinct breaks get distinct labels
301/// and no non-zero break prints as "0" — e.g. breaks 0.990/0.992/0.994 read
302/// "0.99, 0.992, 0.994" instead of "0.99, 0.99, 0.99", and a log tick at 0.001
303/// no longer reads "0". Output is unchanged whenever 2 decimals suffice.
304pub fn format_numbers(values: &[f64]) -> Vec<String> {
305    fn fmt(v: f64, decimals: usize) -> String {
306        if v == v.round() && v.abs() < 1e10 {
307            return format!("{}", v as i64);
308        }
309        let s = format!("{v:.decimals$}");
310        let s = s.trim_end_matches('0').trim_end_matches('.');
311        if s == "-0" {
312            "0".to_string()
313        } else {
314            s.to_string()
315        }
316    }
317    let ok = |labels: &[String]| {
318        let distinct = values
319            .iter()
320            .zip(labels)
321            .all(|(a, la)| values.iter().zip(labels).all(|(b, lb)| a == b || la != lb));
322        let nonzero = values
323            .iter()
324            .zip(labels)
325            .all(|(v, l)| *v == 0.0 || l != "0");
326        distinct && nonzero
327    };
328    let mut labels = Vec::new();
329    for decimals in 2..=12 {
330        labels = values.iter().map(|&v| fmt(v, decimals)).collect();
331        if ok(&labels) {
332            break;
333        }
334    }
335    labels
336}
337
338pub fn format_number(v: f64) -> String {
339    if v == v.round() && v.abs() < 1e10 {
340        format!("{}", v as i64)
341    } else {
342        let s = format!("{:.2}", v);
343        s.trim_end_matches('0').trim_end_matches('.').to_string()
344    }
345}
346
347/// [`format_numbers`] rendered with the marks of `locale`: the same number of
348/// decimals is chosen (so distinct breaks keep distinct labels), then the
349/// labels get the locale's decimal mark and thousands grouping —
350/// `[1000, 1500]` reads `"1.000", "1.500"` in de-DE.
351pub fn format_numbers_locale(values: &[f64], locale: &crate::locale::Locale) -> Vec<String> {
352    format_numbers(values)
353        .into_iter()
354        .map(|s| locale.localize_number(&s))
355        .collect()
356}
357
358/// [`format_number`] rendered with the marks of `locale`.
359pub fn format_number_locale(v: f64, locale: &crate::locale::Locale) -> String {
360    locale.localize_number(&format_number(v))
361}
362
363/// [`format_numbers`] with an optional locale (`None` = default marks).
364pub(crate) fn format_numbers_opt(
365    values: &[f64],
366    locale: Option<&crate::locale::Locale>,
367) -> Vec<String> {
368    match locale {
369        Some(l) => format_numbers_locale(values, l),
370        None => format_numbers(values),
371    }
372}
373
374/// [`format_number`] with an optional locale (`None` = default marks).
375pub(crate) fn format_number_opt(v: f64, locale: Option<&crate::locale::Locale>) -> String {
376    match locale {
377        Some(l) => format_number_locale(v, l),
378        None => format_number(v),
379    }
380}
381
382#[cfg(test)]
383mod log_breaks_tests {
384    use super::*;
385
386    #[test]
387    fn wide_range_ticks_whole_decades() {
388        // A span of several decades: whole powers of 10 are plenty for 5 ticks.
389        assert_eq!(
390            log_breaks(1.0, 1000.0, 5, 10.0),
391            vec![1.0, 10.0, 100.0, 1000.0]
392        );
393    }
394
395    #[test]
396    fn narrow_range_falls_back_to_sub_decade_steps() {
397        // Issue #413: 1..170 spans barely two decades — too few for 5 ticks at
398        // whole powers of 10 alone (ggplot2 fills in with 1-2-5-style steps).
399        let breaks = log_breaks(1.0, 170.0, 5, 10.0);
400        // Every tick must be a "round" number, never a half-decade like 316.23.
401        for &b in &breaks {
402            let digits = format!("{b}");
403            assert!(!digits.contains('.'), "non-round log break: {b}");
404        }
405        assert!(breaks.contains(&1.0));
406        assert!(breaks.iter().any(|&b| b >= 100.0));
407    }
408
409    #[test]
410    fn base2_ticks_whole_powers_only() {
411        assert_eq!(log_breaks(1.0, 8.0, 5, 2.0), vec![1.0, 2.0, 4.0, 8.0]);
412    }
413
414    #[test]
415    fn degenerate_or_invalid_range_falls_back() {
416        assert!(log_breaks(5.0, 5.0, 5, 10.0).is_empty());
417        // Non-positive domain can't be logged — fall back to linear extended
418        // breaks rather than NaN/garbage.
419        assert_eq!(
420            log_breaks(-1.0, 10.0, 5, 10.0),
421            extended_breaks(-1.0, 10.0, 5)
422        );
423    }
424}
425
426#[cfg(test)]
427mod locale_tests {
428    use super::*;
429    use crate::locale::Locale;
430
431    #[test]
432    fn joint_labels_keep_distinctness_with_locale_marks() {
433        let de = Locale::de_de();
434        assert_eq!(
435            format_numbers_locale(&[0.99, 0.992, 0.994], &de),
436            ["0,99", "0,992", "0,994"]
437        );
438        assert_eq!(
439            format_numbers_locale(&[0.0, 1000.0, 2000.0, 3000.0], &de),
440            ["0", "1.000", "2.000", "3.000"]
441        );
442        assert_eq!(
443            format_numbers_locale(&[-1500.5, 0.0, 1500.5], &de),
444            ["-1.500,5", "0", "1.500,5"]
445        );
446        assert_eq!(
447            format_numbers_locale(&[1000.0, 1000.001, 1000.002], &de),
448            ["1.000", "1.000,001", "1.000,002"]
449        );
450        assert_eq!(
451            format_numbers_locale(&[0.001, 0.01, 0.1], &Locale::fr_fr()),
452            ["0,001", "0,01", "0,1"]
453        );
454        let us = Locale::en_us();
455        assert_eq!(
456            format_numbers_locale(&[0.0, 2500.0, 5000.0], &us),
457            ["0", "2,500", "5,000"]
458        );
459        assert_eq!(format_number_locale(1234.5678, &de), "1.234,57");
460        // No locale: unchanged default output.
461        assert_eq!(format_numbers_opt(&[0.0, 1000.0], None), ["0", "1000"]);
462        assert_eq!(format_number_opt(1234.5, None), "1234.5");
463    }
464}