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/// Round time-of-day breaks over `[dmin, dmax]` seconds (#408): the step is
29/// the smallest of 1/2/5/10/15/30 s, 1/2/5/10/15/30 min, 1/2/3/6/12 h, 1 day
30/// (or whole days) giving at most about `m` intervals.
31pub fn time_breaks(dmin: f64, dmax: f64, m: usize) -> Vec<f64> {
32    if !(dmin.is_finite() && dmax.is_finite()) || dmax <= dmin {
33        return vec![];
34    }
35    const STEPS: [f64; 18] = [
36        1.0, 2.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0, 900.0, 1800.0, 3600.0, 7200.0,
37        10800.0, 21600.0, 43200.0, 86400.0,
38    ];
39    let span = dmax - dmin;
40    let m = m.max(2) as f64;
41    let step = STEPS
42        .iter()
43        .copied()
44        .find(|s| span / s <= m)
45        .unwrap_or_else(|| (span / m / 86400.0).ceil() * 86400.0);
46    let mut v = (dmin / step).ceil() * step;
47    let mut out = Vec::new();
48    while v <= dmax + step * 1e-9 {
49        out.push(v);
50        v += step;
51    }
52    out
53}
54
55/// Seconds as `HH:MM:SS` (`hms`; hours may exceed 24, negatives get a
56/// leading `-`). Fractional seconds are rounded.
57pub fn format_hms(secs: f64) -> String {
58    if !secs.is_finite() {
59        return "NA".into();
60    }
61    let sign = if secs < 0.0 { "-" } else { "" };
62    let t = secs.abs().round() as u64;
63    format!("{sign}{:02}:{:02}:{:02}", t / 3600, (t / 60) % 60, t % 60)
64}
65
66/// Extended-Wilkinson tick locations (Talbot, Lin & Hanrahan 2010), matching R's
67/// `scales::extended_breaks()` / `labeling::extended()`. Returns "nice" break
68/// values covering `[dmin, dmax]` with roughly `m` labels.
69pub fn extended_breaks(dmin: f64, dmax: f64, m: usize) -> Vec<f64> {
70    if !(dmin.is_finite() && dmax.is_finite()) || dmax <= dmin || m < 2 {
71        return vec![];
72    }
73    // A range that is negligible relative to the magnitude of the data (e.g.
74    // 1-ulp float noise from SUM/AVG) has no meaningful breaks, and the search
75    // below would need more than 2^53 candidate starts to cover it.
76    if is_degenerate_range(dmin, dmax) {
77        return vec![];
78    }
79    // Preferred step mantissas and score weights (simplicity, coverage, density,
80    // legibility) — the paper's defaults, as in the `labeling` package.
81    const Q: [f64; 6] = [1.0, 5.0, 2.0, 2.5, 4.0, 3.0];
82    const W: [f64; 4] = [0.25, 0.2, 0.5, 0.05];
83    let m = m as f64;
84
85    let simplicity = |qi: usize, j: f64, lmin: f64, lmax: f64, step: f64| {
86        let eps = 1e-10;
87        let modulo = lmin.rem_euclid(step);
88        let v = if (modulo < eps || step - modulo < eps) && lmin <= 0.0 && lmax >= 0.0 {
89            1.0
90        } else {
91            0.0
92        };
93        1.0 - qi as f64 / (Q.len() as f64 - 1.0) - j + v
94    };
95    let simplicity_max = |qi: usize, j: f64| 1.0 - qi as f64 / (Q.len() as f64 - 1.0) - j + 1.0;
96    let coverage = |lmin: f64, lmax: f64| {
97        let r = dmax - dmin;
98        1.0 - 0.5 * ((dmax - lmax).powi(2) + (dmin - lmin).powi(2)) / (0.1 * r).powi(2)
99    };
100    let coverage_max = |span: f64| {
101        let r = dmax - dmin;
102        if span > r {
103            let half = (span - r) / 2.0;
104            1.0 - 0.5 * (half * half + half * half) / (0.1 * r).powi(2)
105        } else {
106            1.0
107        }
108    };
109    let density = |k: f64, lmin: f64, lmax: f64| {
110        let r = (k - 1.0) / (lmax - lmin);
111        let rt = (m - 1.0) / (lmax.max(dmax) - lmin.min(dmin));
112        2.0 - (r / rt).max(rt / r)
113    };
114    let density_max = |k: f64| {
115        if k >= m {
116            2.0 - (k - 1.0) / (m - 1.0)
117        } else {
118            1.0
119        }
120    };
121
122    let mut best_score = -2.0;
123    let mut best = (dmin, dmax, (dmax - dmin) / (m - 1.0));
124
125    for j_i in 1..=4u32 {
126        let j = j_i as f64;
127        for (qi, &q) in Q.iter().enumerate() {
128            let sm = simplicity_max(qi, j);
129            if W[0] * sm + W[1] + W[2] + W[3] < best_score {
130                break;
131            }
132            for k_i in 2..=(2.0 * m + 4.0) as usize {
133                let k = k_i as f64;
134                let dm = density_max(k);
135                if W[0] * sm + W[1] + W[2] * dm + W[3] < best_score {
136                    break;
137                }
138                let delta = (dmax - dmin) / (k + 1.0) / j / q;
139                let z0 = delta.log10().ceil() as i32;
140                for z in z0..(z0 + 6) {
141                    let step = j * q * 10f64.powi(z);
142                    let cm = coverage_max(step * (k - 1.0));
143                    if W[0] * sm + W[1] * cm + W[2] * dm + W[3] < best_score {
144                        break;
145                    }
146                    let min_start = (dmax / step).floor() * j - (k - 1.0) * j;
147                    let max_start = (dmin / step).ceil() * j;
148                    if min_start > max_start {
149                        continue;
150                    }
151                    // Integer-driven so the loop always terminates: once
152                    // `start` exceeds 2^53, `start += 1.0` no longer changes it.
153                    let n_starts = max_start - min_start;
154                    if !n_starts.is_finite() || n_starts > MAX_START_CANDIDATES {
155                        continue;
156                    }
157                    for si in 0..=(n_starts as u64) {
158                        let start = min_start + si as f64;
159                        let lmin = start * step / j;
160                        let lmax = lmin + step * (k - 1.0);
161                        let s = simplicity(qi, j, lmin, lmax, step);
162                        let c = coverage(lmin, lmax);
163                        let g = density(k, lmin, lmax);
164                        let score = W[0] * s + W[1] * c + W[2] * g + W[3];
165                        if score > best_score {
166                            best_score = score;
167                            best = (lmin, lmax, step);
168                        }
169                    }
170                }
171            }
172        }
173    }
174
175    let (lmin, lmax, step) = best;
176    if step <= 0.0 {
177        return vec![];
178    }
179    let n = ((lmax - lmin) / step).round() as usize;
180    (0..=n).map(|i| lmin + i as f64 * step).collect()
181}
182
183/// Round ticks for a log-transformed axis, matching R's `scales::log_breaks()`:
184/// prefers `base^n`; when the range spans too few decades for `m` ticks, mixes
185/// in evenly-spaced sub-multiples of `base` (e.g. `{1,2,5} * 10^n` for base 10);
186/// falls back to [`extended_breaks`] over the *data* range `[dmin, dmax]` when
187/// neither gives enough ticks. `dmin`/`dmax` are in data (pre-transform) space
188/// and must be finite and positive.
189pub fn log_breaks(dmin: f64, dmax: f64, m: usize, base: f64) -> Vec<f64> {
190    let m = m.max(2);
191    if !(dmin.is_finite() && dmax.is_finite())
192        || dmin <= 0.0
193        || dmax <= 0.0
194        || dmax < dmin
195        || base <= 1.0
196    {
197        return extended_breaks(dmin, dmax, m);
198    }
199    if is_degenerate_range(dmin, dmax) {
200        return vec![];
201    }
202
203    let log_min = dmin.log(base);
204    let log_max = dmax.log(base);
205    let min_e = log_min.floor();
206    let max_e = log_max.ceil();
207    if (max_e - min_e).abs() < 1e-9 {
208        return vec![base.powf(min_e)];
209    }
210
211    let want = (m as i64 - 2).max(0) as f64;
212    let exp_seq = |by: f64| -> Vec<f64> {
213        let mut out = Vec::new();
214        let mut e = min_e;
215        while e <= max_e + 1e-9 {
216            out.push(base.powf(e));
217            e += by;
218        }
219        out
220    };
221    let count_relevant = |breaks: &[f64]| -> usize {
222        let lo = base.powf(log_min) * (1.0 - 1e-9);
223        let hi = base.powf(log_max) * (1.0 + 1e-9);
224        breaks.iter().filter(|&&b| b >= lo && b <= hi).count()
225    };
226
227    let mut by = ((max_e - min_e) / m as f64).floor() + 1.0;
228    loop {
229        let breaks = exp_seq(by);
230        if count_relevant(&breaks) as f64 >= want {
231            return breaks;
232        }
233        if by <= 1.0 {
234            break;
235        }
236        by -= 1.0;
237    }
238
239    log_sub_breaks(dmin, dmax, min_e, max_e, m, base)
240}
241
242/// Sub-decade ticks for [`log_breaks`] once whole-decade (or whole-`by`)
243/// steps don't give enough of them — mirrors `scales:::log_sub_breaks()`.
244fn log_sub_breaks(dmin: f64, dmax: f64, min_e: f64, max_e: f64, m: usize, base: f64) -> Vec<f64> {
245    let want = (m as i64 - 2).max(0) as f64;
246    let lo = dmin * (1.0 - 1e-9);
247    let hi = dmax * (1.0 + 1e-9);
248
249    if base <= 2.0 {
250        let mut out = Vec::new();
251        let mut e = min_e;
252        while e <= max_e + 1e-9 {
253            out.push(base.powf(e));
254            e += 1.0;
255        }
256        return out;
257    }
258
259    let exps: Vec<i64> = ((min_e as i64)..=(max_e as i64)).collect();
260    let mut steps: Vec<f64> = vec![1.0];
261    // Integer sub-multiples strictly between 1 and `base` (R's
262    // `seq_len(base)` filtered to `1 < candidate < base`).
263    let mut candidates: Vec<f64> = (2..=(base.floor() as i64))
264        .filter(|&x| (x as f64) < base)
265        .map(|x| x as f64)
266        .collect();
267
268    // Greedily add whichever remaining candidate leaves the largest gap
269    // (in log space) between consecutive steps, same as the R reference.
270    let delta = |x: f64, steps: &[f64]| -> f64 {
271        let mut vals: Vec<f64> = steps.to_vec();
272        vals.push(x);
273        vals.push(base);
274        vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
275        vals.windows(2)
276            .map(|w| w[1].log(base) - w[0].log(base))
277            .fold(f64::INFINITY, f64::min)
278    };
279
280    let mut breaks: Vec<f64> = Vec::new();
281    let mut relevant = 0usize;
282    while !candidates.is_empty() {
283        let (best_i, _) = candidates
284            .iter()
285            .enumerate()
286            .map(|(i, &c)| (i, delta(c, &steps)))
287            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
288            .unwrap();
289        steps.push(candidates.remove(best_i));
290        breaks = exps
291            .iter()
292            .flat_map(|&e| steps.iter().map(move |&s| base.powf(e as f64) * s))
293            .collect();
294        relevant = breaks.iter().filter(|&&b| b >= lo && b <= hi).count();
295        if relevant as f64 >= want {
296            break;
297        }
298    }
299
300    if relevant as f64 >= want && !breaks.is_empty() {
301        breaks.sort_by(|a, b| a.partial_cmp(b).unwrap());
302        let first_in = breaks.iter().position(|&b| b >= lo);
303        let last_in = breaks.iter().rposition(|&b| b <= hi);
304        match (first_in, last_in) {
305            (Some(fi), Some(li)) => {
306                let start = fi.saturating_sub(1);
307                let end = (li + 1).min(breaks.len() - 1);
308                breaks[start..=end].to_vec()
309            }
310            _ => breaks,
311        }
312    } else {
313        extended_breaks(dmin, dmax, m)
314    }
315}
316
317/// Round step size to a "nice" value (1, 2, 5, 10, 20, 50, ...).
318pub fn nice_step(raw: f64) -> f64 {
319    let magnitude = 10f64.powf(raw.abs().log10().floor());
320    let fraction = raw / magnitude;
321
322    let nice = if fraction <= 1.5 {
323        1.0
324    } else if fraction <= 3.5 {
325        2.0
326    } else if fraction <= 7.5 {
327        5.0
328    } else {
329        10.0
330    };
331
332    nice * magnitude
333}
334
335/// Format a number nicely, removing trailing zeros.
336/// Format a set of break values together: like [`format_number`] (integers
337/// verbatim, otherwise up to 2 decimals, trailing zeros trimmed), but with as
338/// many extra decimals as needed so that distinct breaks get distinct labels
339/// and no non-zero break prints as "0" — e.g. breaks 0.990/0.992/0.994 read
340/// "0.99, 0.992, 0.994" instead of "0.99, 0.99, 0.99", and a log tick at 0.001
341/// no longer reads "0". Output is unchanged whenever 2 decimals suffice.
342pub fn format_numbers(values: &[f64]) -> Vec<String> {
343    fn fmt(v: f64, decimals: usize) -> String {
344        if v == v.round() && v.abs() < 1e10 {
345            return format!("{}", v as i64);
346        }
347        let s = format!("{v:.decimals$}");
348        let s = s.trim_end_matches('0').trim_end_matches('.');
349        if s == "-0" {
350            "0".to_string()
351        } else {
352            s.to_string()
353        }
354    }
355    let ok = |labels: &[String]| {
356        let distinct = values
357            .iter()
358            .zip(labels)
359            .all(|(a, la)| values.iter().zip(labels).all(|(b, lb)| a == b || la != lb));
360        let nonzero = values
361            .iter()
362            .zip(labels)
363            .all(|(v, l)| *v == 0.0 || l != "0");
364        distinct && nonzero
365    };
366    let mut labels = Vec::new();
367    for decimals in 2..=12 {
368        labels = values.iter().map(|&v| fmt(v, decimals)).collect();
369        if ok(&labels) {
370            break;
371        }
372    }
373    labels
374}
375
376pub fn format_number(v: f64) -> String {
377    if v == v.round() && v.abs() < 1e10 {
378        format!("{}", v as i64)
379    } else {
380        let s = format!("{:.2}", v);
381        s.trim_end_matches('0').trim_end_matches('.').to_string()
382    }
383}
384
385/// [`format_numbers`] rendered with the marks of `locale`: the same number of
386/// decimals is chosen (so distinct breaks keep distinct labels), then the
387/// labels get the locale's decimal mark and thousands grouping —
388/// `[1000, 1500]` reads `"1.000", "1.500"` in de-DE.
389pub fn format_numbers_locale(values: &[f64], locale: &crate::locale::Locale) -> Vec<String> {
390    format_numbers(values)
391        .into_iter()
392        .map(|s| locale.localize_number(&s))
393        .collect()
394}
395
396/// [`format_number`] rendered with the marks of `locale`.
397pub fn format_number_locale(v: f64, locale: &crate::locale::Locale) -> String {
398    locale.localize_number(&format_number(v))
399}
400
401/// [`format_numbers`] with an optional locale (`None` = default marks).
402pub(crate) fn format_numbers_opt(
403    values: &[f64],
404    locale: Option<&crate::locale::Locale>,
405) -> Vec<String> {
406    match locale {
407        Some(l) => format_numbers_locale(values, l),
408        None => format_numbers(values),
409    }
410}
411
412/// [`format_number`] with an optional locale (`None` = default marks).
413pub(crate) fn format_number_opt(v: f64, locale: Option<&crate::locale::Locale>) -> String {
414    match locale {
415        Some(l) => format_number_locale(v, l),
416        None => format_number(v),
417    }
418}
419
420#[cfg(test)]
421mod log_breaks_tests {
422    use super::*;
423
424    #[test]
425    fn wide_range_ticks_whole_decades() {
426        // A span of several decades: whole powers of 10 are plenty for 5 ticks.
427        assert_eq!(
428            log_breaks(1.0, 1000.0, 5, 10.0),
429            vec![1.0, 10.0, 100.0, 1000.0]
430        );
431    }
432
433    #[test]
434    fn narrow_range_falls_back_to_sub_decade_steps() {
435        // Issue #413: 1..170 spans barely two decades — too few for 5 ticks at
436        // whole powers of 10 alone (ggplot2 fills in with 1-2-5-style steps).
437        let breaks = log_breaks(1.0, 170.0, 5, 10.0);
438        // Every tick must be a "round" number, never a half-decade like 316.23.
439        for &b in &breaks {
440            let digits = format!("{b}");
441            assert!(!digits.contains('.'), "non-round log break: {b}");
442        }
443        assert!(breaks.contains(&1.0));
444        assert!(breaks.iter().any(|&b| b >= 100.0));
445    }
446
447    #[test]
448    fn base2_ticks_whole_powers_only() {
449        assert_eq!(log_breaks(1.0, 8.0, 5, 2.0), vec![1.0, 2.0, 4.0, 8.0]);
450    }
451
452    #[test]
453    fn degenerate_or_invalid_range_falls_back() {
454        assert!(log_breaks(5.0, 5.0, 5, 10.0).is_empty());
455        // Non-positive domain can't be logged — fall back to linear extended
456        // breaks rather than NaN/garbage.
457        assert_eq!(
458            log_breaks(-1.0, 10.0, 5, 10.0),
459            extended_breaks(-1.0, 10.0, 5)
460        );
461    }
462}
463
464#[cfg(test)]
465mod locale_tests {
466    use super::*;
467    use crate::locale::Locale;
468
469    #[test]
470    fn joint_labels_keep_distinctness_with_locale_marks() {
471        let de = Locale::de_de();
472        assert_eq!(
473            format_numbers_locale(&[0.99, 0.992, 0.994], &de),
474            ["0,99", "0,992", "0,994"]
475        );
476        assert_eq!(
477            format_numbers_locale(&[0.0, 1000.0, 2000.0, 3000.0], &de),
478            ["0", "1.000", "2.000", "3.000"]
479        );
480        assert_eq!(
481            format_numbers_locale(&[-1500.5, 0.0, 1500.5], &de),
482            ["-1.500,5", "0", "1.500,5"]
483        );
484        assert_eq!(
485            format_numbers_locale(&[1000.0, 1000.001, 1000.002], &de),
486            ["1.000", "1.000,001", "1.000,002"]
487        );
488        assert_eq!(
489            format_numbers_locale(&[0.001, 0.01, 0.1], &Locale::fr_fr()),
490            ["0,001", "0,01", "0,1"]
491        );
492        let us = Locale::en_us();
493        assert_eq!(
494            format_numbers_locale(&[0.0, 2500.0, 5000.0], &us),
495            ["0", "2,500", "5,000"]
496        );
497        assert_eq!(format_number_locale(1234.5678, &de), "1.234,57");
498        // No locale: unchanged default output.
499        assert_eq!(format_numbers_opt(&[0.0, 1000.0], None), ["0", "1000"]);
500        assert_eq!(format_number_opt(1234.5, None), "1234.5");
501    }
502}