Skip to main content

ggplot_rs/scale/
format.rs

1//! Label formatting functions for scale breaks.
2//! Analogous to R's `scales` package (comma, percent, dollar, scientific,
3//! number, SI, ordinal, bytes).
4
5use std::sync::Arc;
6
7/// Format with comma separators for thousands (e.g., 1,234,567).
8pub fn label_comma(v: f64) -> String {
9    if v == v.round() && v.abs() < 1e15 {
10        let s = format!("{}", v as i64);
11        add_commas(&s)
12    } else {
13        let s = format!("{:.2}", v);
14        let s = s.trim_end_matches('0').trim_end_matches('.');
15        if let Some((int_part, dec_part)) = s.split_once('.') {
16            format!("{}.{}", add_commas(int_part), dec_part)
17        } else {
18            add_commas(s)
19        }
20    }
21}
22
23/// Format as percentage (e.g., 0.5 → "50%").
24pub fn label_percent(v: f64) -> String {
25    let pct = v * 100.0;
26    if (pct - pct.round()).abs() < 1e-10 {
27        format!("{}%", pct.round() as i64)
28    } else {
29        format!("{:.1}%", pct)
30    }
31}
32
33/// Format as US dollar (e.g., 1234.5 → "$1,235").
34pub fn label_dollar(v: f64) -> String {
35    if v < 0.0 {
36        format!("-${}", label_comma(-v))
37    } else {
38        format!("${}", label_comma(v))
39    }
40}
41
42/// Format in scientific notation (e.g., 12345 → "1.23e4").
43pub fn label_scientific(v: f64) -> String {
44    if v == 0.0 {
45        return "0".to_string();
46    }
47    let exp = v.abs().log10().floor() as i32;
48    let mantissa = v / 10f64.powi(exp);
49    if (mantissa - mantissa.round()).abs() < 1e-10 {
50        format!("{}e{}", mantissa.round() as i64, exp)
51    } else {
52        let s = format!("{:.2}e{}", mantissa, exp);
53        // Trim trailing zeros in mantissa
54        if let Some((m, e)) = s.split_once('e') {
55            let m = m.trim_end_matches('0').trim_end_matches('.');
56            format!("{m}e{e}")
57        } else {
58            s
59        }
60    }
61}
62
63fn add_commas(s: &str) -> String {
64    let negative = s.starts_with('-');
65    let digits = if negative { &s[1..] } else { s };
66    let mut result = String::new();
67    for (i, ch) in digits.chars().rev().enumerate() {
68        if i > 0 && i % 3 == 0 {
69            result.push(',');
70        }
71        result.push(ch);
72    }
73    let formatted: String = result.chars().rev().collect();
74    if negative {
75        format!("-{formatted}")
76    } else {
77        formatted
78    }
79}
80
81/// A label formatter — any `Fn(f64) -> String`. The plain `label_*` functions
82/// coerce into this, and the configurable `label_number`/`label_si`/… builders
83/// return one directly.
84pub type LabelFormatter = Arc<dyn Fn(f64) -> String + Send + Sync>;
85
86/// Round `v` to a multiple of `accuracy` and format with the implied decimals.
87fn format_accuracy(v: f64, accuracy: Option<f64>) -> String {
88    match accuracy {
89        Some(acc) if acc > 0.0 => {
90            let rounded = (v / acc).round() * acc;
91            let decimals = (-acc.log10().floor()).max(0.0) as usize;
92            add_commas(&format!("{rounded:.decimals$}"))
93        }
94        _ => label_comma(v),
95    }
96}
97
98/// General configurable number formatter (R's `scales::label_number`).
99/// Multiplies by `scale`, rounds to `accuracy` (None = trim), and wraps in
100/// `prefix`/`suffix`.
101pub fn label_number(
102    accuracy: Option<f64>,
103    prefix: &str,
104    suffix: &str,
105    scale: f64,
106) -> impl Fn(f64) -> String + Send + Sync {
107    let prefix = prefix.to_string();
108    let suffix = suffix.to_string();
109    move |v: f64| format!("{prefix}{}{suffix}", format_accuracy(v * scale, accuracy))
110}
111
112/// SI-prefixed number formatter: 1_500 → "1.5k", 2.3e6 → "2.3M", 5e-4 → "500µ".
113pub fn label_si() -> impl Fn(f64) -> String + Send + Sync {
114    |v: f64| {
115        if v == 0.0 {
116            return "0".to_string();
117        }
118        let a = v.abs();
119        let (div, suffix) = if a >= 1e12 {
120            (1e12, "T")
121        } else if a >= 1e9 {
122            (1e9, "G")
123        } else if a >= 1e6 {
124            (1e6, "M")
125        } else if a >= 1e3 {
126            (1e3, "k")
127        } else if a >= 1.0 {
128            (1.0, "")
129        } else if a >= 1e-3 {
130            (1e-3, "m")
131        } else if a >= 1e-6 {
132            (1e-6, "µ")
133        } else {
134            (1e-9, "n")
135        };
136        let scaled = v / div;
137        let s = format!("{scaled:.1}");
138        let s = s.trim_end_matches('0').trim_end_matches('.');
139        format!("{s}{suffix}")
140    }
141}
142
143/// Ordinal formatter: 1 → "1st", 2 → "2nd", 3 → "3rd", 11 → "11th".
144pub fn label_ordinal() -> impl Fn(f64) -> String + Send + Sync {
145    |v: f64| {
146        let n = v.round() as i64;
147        let suffix = match (n.rem_euclid(10), n.rem_euclid(100)) {
148            (1, r) if r != 11 => "st",
149            (2, r) if r != 12 => "nd",
150            (3, r) if r != 13 => "rd",
151            _ => "th",
152        };
153        format!("{n}{suffix}")
154    }
155}
156
157/// Byte-size formatter. `binary = true` uses 1024-based KiB/MiB; otherwise
158/// 1000-based kB/MB.
159pub fn label_bytes(binary: bool) -> impl Fn(f64) -> String + Send + Sync {
160    let (base, units): (f64, &[&str]) = if binary {
161        (1024.0, &["B", "KiB", "MiB", "GiB", "TiB"])
162    } else {
163        (1000.0, &["B", "kB", "MB", "GB", "TB"])
164    };
165    move |v: f64| {
166        let a = v.abs();
167        if a < base {
168            return format!("{} {}", v.round() as i64, units[0]);
169        }
170        let mut val = a;
171        let mut i = 0;
172        while val >= base && i < units.len() - 1 {
173            val /= base;
174            i += 1;
175        }
176        let s = format!("{val:.1}");
177        let s = s.trim_end_matches('0').trim_end_matches('.');
178        let sign = if v < 0.0 { "-" } else { "" };
179        format!("{sign}{s} {}", units[i])
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_label_comma() {
189        assert_eq!(label_comma(1000.0), "1,000");
190        assert_eq!(label_comma(1234567.0), "1,234,567");
191        assert_eq!(label_comma(42.0), "42");
192        assert_eq!(label_comma(-5000.0), "-5,000");
193    }
194
195    #[test]
196    fn test_label_percent() {
197        assert_eq!(label_percent(0.5), "50%");
198        assert_eq!(label_percent(0.0), "0%");
199        assert_eq!(label_percent(1.0), "100%");
200        assert_eq!(label_percent(0.123), "12.3%");
201    }
202
203    #[test]
204    fn test_label_dollar() {
205        assert_eq!(label_dollar(1000.0), "$1,000");
206        assert_eq!(label_dollar(0.0), "$0");
207        assert_eq!(label_dollar(-500.0), "-$500");
208    }
209
210    #[test]
211    fn test_label_scientific() {
212        assert_eq!(label_scientific(12345.0), "1.23e4");
213        assert_eq!(label_scientific(0.0), "0");
214        assert_eq!(label_scientific(100.0), "1e2");
215    }
216
217    #[test]
218    fn test_label_si() {
219        let f = label_si();
220        assert_eq!(f(1500.0), "1.5k");
221        assert_eq!(f(2_300_000.0), "2.3M");
222        assert_eq!(f(5e9), "5G");
223        assert_eq!(f(0.0), "0");
224        assert_eq!(f(0.0005), "500µ");
225        assert_eq!(f(-4000.0), "-4k");
226    }
227
228    #[test]
229    fn test_label_number() {
230        let f = label_number(Some(0.1), "", " kg", 1.0);
231        assert_eq!(f(4.16), "4.2 kg");
232        let pct = label_number(Some(1.0), "", "%", 100.0);
233        assert_eq!(pct(0.25), "25%");
234        let money = label_number(None, "€", "", 1.0);
235        assert_eq!(money(1500.0), "€1,500");
236    }
237
238    #[test]
239    fn test_label_ordinal() {
240        let f = label_ordinal();
241        assert_eq!(f(1.0), "1st");
242        assert_eq!(f(2.0), "2nd");
243        assert_eq!(f(3.0), "3rd");
244        assert_eq!(f(4.0), "4th");
245        assert_eq!(f(11.0), "11th");
246        assert_eq!(f(22.0), "22nd");
247    }
248
249    #[test]
250    fn test_label_bytes() {
251        let f = label_bytes(false);
252        assert_eq!(f(500.0), "500 B");
253        assert_eq!(f(1500.0), "1.5 kB");
254        assert_eq!(f(2_000_000.0), "2 MB");
255        let b = label_bytes(true);
256        assert_eq!(b(1024.0), "1 KiB");
257    }
258}