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 with thousands separators (e.g., 1234 → "$1,234",
34/// -500 → "-$500"). Equivalent to `label_currency("$", "", ",", None)`.
35pub fn label_dollar(v: f64) -> String {
36    currency(v, "$", "", ",", None)
37}
38
39/// Sign-aware currency formatter (R's `scales::label_currency`): the minus sign
40/// goes *before* the prefix ("-€5", not "€-5"), thousands are grouped with
41/// `big_mark` (e.g. `","`, `"."`, `" "`, `"'"`; `""` disables grouping), and
42/// `accuracy` rounds to a multiple of it (`None` = up to two decimals, trailing
43/// zeros trimmed). The decimal mark is always `.`.
44///
45/// ```
46/// use ggplot_rs::scale::format::label_currency;
47/// let eur = label_currency("€", "", ".", Some(1.0));
48/// assert_eq!(eur(-1234567.0), "-€1.234.567");
49/// let chf = label_currency("", " CHF", "'", Some(0.01));
50/// assert_eq!(chf(9876.5), "9'876.50 CHF");
51/// ```
52pub fn label_currency(
53    prefix: &str,
54    suffix: &str,
55    big_mark: &str,
56    accuracy: Option<f64>,
57) -> impl Fn(f64) -> String + Send + Sync {
58    let prefix = prefix.to_string();
59    let suffix = suffix.to_string();
60    let big_mark = big_mark.to_string();
61    move |v: f64| currency(v, &prefix, &suffix, &big_mark, accuracy)
62}
63
64/// Shared body of [`label_currency`] / [`label_dollar`].
65fn currency(v: f64, prefix: &str, suffix: &str, big_mark: &str, accuracy: Option<f64>) -> String {
66    // Format the magnitude, then put the sign in front of the prefix. NaN and
67    // ±inf pass through as "NaN" / "inf".
68    let (_, body) = split_sign(format_accuracy(v.abs(), accuracy));
69    let sign = if v < 0.0 && !is_zero_number(&body) {
70        "-"
71    } else {
72        ""
73    };
74    let body = if big_mark == "," {
75        body
76    } else {
77        body.replace(',', big_mark)
78    };
79    format!("{sign}{prefix}{body}{suffix}")
80}
81
82/// Split a leading `-` off a formatted number.
83fn split_sign(s: String) -> (&'static str, String) {
84    match s.strip_prefix('-') {
85        Some(rest) => ("-", rest.to_string()),
86        None => ("", s),
87    }
88}
89
90/// True when a formatted number is (rounded to) zero, e.g. "0", "0.00".
91fn is_zero_number(s: &str) -> bool {
92    s.chars().all(|c| matches!(c, '0' | '.' | ','))
93}
94
95/// Format in scientific notation (e.g., 12345 → "1.23e4").
96pub fn label_scientific(v: f64) -> String {
97    if v == 0.0 {
98        return "0".to_string();
99    }
100    let exp = v.abs().log10().floor() as i32;
101    let mantissa = v / 10f64.powi(exp);
102    if (mantissa - mantissa.round()).abs() < 1e-10 {
103        format!("{}e{}", mantissa.round() as i64, exp)
104    } else {
105        let s = format!("{:.2}e{}", mantissa, exp);
106        // Trim trailing zeros in mantissa
107        if let Some((m, e)) = s.split_once('e') {
108            let m = m.trim_end_matches('0').trim_end_matches('.');
109            format!("{m}e{e}")
110        } else {
111            s
112        }
113    }
114}
115
116fn add_commas(s: &str) -> String {
117    let negative = s.starts_with('-');
118    let digits = if negative { &s[1..] } else { s };
119    let mut result = String::new();
120    for (i, ch) in digits.chars().rev().enumerate() {
121        if i > 0 && i % 3 == 0 {
122            result.push(',');
123        }
124        result.push(ch);
125    }
126    let formatted: String = result.chars().rev().collect();
127    if negative {
128        format!("-{formatted}")
129    } else {
130        formatted
131    }
132}
133
134/// A label formatter — any `Fn(f64) -> String`. The plain `label_*` functions
135/// coerce into this, and the configurable `label_number`/`label_si`/… builders
136/// return one directly.
137pub type LabelFormatter = Arc<dyn Fn(f64) -> String + Send + Sync>;
138
139/// Round `v` to a multiple of `accuracy` and format with the implied decimals.
140fn format_accuracy(v: f64, accuracy: Option<f64>) -> String {
141    match accuracy {
142        Some(acc) if acc > 0.0 => {
143            let rounded = (v / acc).round() * acc;
144            let decimals = (-acc.log10().floor()).max(0.0) as usize;
145            let s = format!("{rounded:.decimals$}");
146            // Group only the integer part ("1234.50" → "1,234.50").
147            match s.split_once('.') {
148                Some((int_part, dec_part)) => format!("{}.{dec_part}", add_commas(int_part)),
149                None => add_commas(&s),
150            }
151        }
152        _ => label_comma(v),
153    }
154}
155
156/// General configurable number formatter (R's `scales::label_number`).
157/// Multiplies by `scale`, rounds to `accuracy` (None = trim), groups thousands
158/// with `,`, and wraps in `prefix`/`suffix` — with the minus sign placed before
159/// the prefix (`-€5`). For a custom thousands mark use [`label_currency`].
160pub fn label_number(
161    accuracy: Option<f64>,
162    prefix: &str,
163    suffix: &str,
164    scale: f64,
165) -> impl Fn(f64) -> String + Send + Sync {
166    let prefix = prefix.to_string();
167    let suffix = suffix.to_string();
168    move |v: f64| {
169        // Sign-aware: "-€5", never "€-5"; a value that rounds to zero loses
170        // its sign ("€0", not "-€0").
171        let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
172        let sign = if is_zero_number(&body) { "" } else { sign };
173        format!("{sign}{prefix}{body}{suffix}")
174    }
175}
176
177/// SI-prefixed number formatter: 1_500 → "1.5k", 2.3e6 → "2.3M", 5e-4 → "500µ".
178pub fn label_si() -> impl Fn(f64) -> String + Send + Sync {
179    |v: f64| {
180        if v == 0.0 {
181            return "0".to_string();
182        }
183        let a = v.abs();
184        let (div, suffix) = if a >= 1e12 {
185            (1e12, "T")
186        } else if a >= 1e9 {
187            (1e9, "G")
188        } else if a >= 1e6 {
189            (1e6, "M")
190        } else if a >= 1e3 {
191            (1e3, "k")
192        } else if a >= 1.0 {
193            (1.0, "")
194        } else if a >= 1e-3 {
195            (1e-3, "m")
196        } else if a >= 1e-6 {
197            (1e-6, "µ")
198        } else {
199            (1e-9, "n")
200        };
201        let scaled = v / div;
202        let s = format!("{scaled:.1}");
203        let s = s.trim_end_matches('0').trim_end_matches('.');
204        format!("{s}{suffix}")
205    }
206}
207
208/// Ordinal formatter: 1 → "1st", 2 → "2nd", 3 → "3rd", 11 → "11th".
209pub fn label_ordinal() -> impl Fn(f64) -> String + Send + Sync {
210    |v: f64| {
211        let n = v.round() as i64;
212        let suffix = match (n.rem_euclid(10), n.rem_euclid(100)) {
213            (1, r) if r != 11 => "st",
214            (2, r) if r != 12 => "nd",
215            (3, r) if r != 13 => "rd",
216            _ => "th",
217        };
218        format!("{n}{suffix}")
219    }
220}
221
222/// Byte-size formatter. `binary = true` uses 1024-based KiB/MiB; otherwise
223/// 1000-based kB/MB.
224pub fn label_bytes(binary: bool) -> impl Fn(f64) -> String + Send + Sync {
225    let (base, units): (f64, &[&str]) = if binary {
226        (1024.0, &["B", "KiB", "MiB", "GiB", "TiB"])
227    } else {
228        (1000.0, &["B", "kB", "MB", "GB", "TB"])
229    };
230    move |v: f64| {
231        let a = v.abs();
232        if a < base {
233            return format!("{} {}", v.round() as i64, units[0]);
234        }
235        let mut val = a;
236        let mut i = 0;
237        while val >= base && i < units.len() - 1 {
238            val /= base;
239            i += 1;
240        }
241        let s = format!("{val:.1}");
242        let s = s.trim_end_matches('0').trim_end_matches('.');
243        let sign = if v < 0.0 { "-" } else { "" };
244        format!("{sign}{s} {}", units[i])
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_label_comma() {
254        assert_eq!(label_comma(1000.0), "1,000");
255        assert_eq!(label_comma(1234567.0), "1,234,567");
256        assert_eq!(label_comma(42.0), "42");
257        assert_eq!(label_comma(-5000.0), "-5,000");
258    }
259
260    #[test]
261    fn test_label_percent() {
262        assert_eq!(label_percent(0.5), "50%");
263        assert_eq!(label_percent(0.0), "0%");
264        assert_eq!(label_percent(1.0), "100%");
265        assert_eq!(label_percent(0.123), "12.3%");
266    }
267
268    #[test]
269    fn test_label_dollar() {
270        assert_eq!(label_dollar(1000.0), "$1,000");
271        assert_eq!(label_dollar(0.0), "$0");
272        assert_eq!(label_dollar(-500.0), "-$500");
273    }
274
275    #[test]
276    fn test_label_scientific() {
277        assert_eq!(label_scientific(12345.0), "1.23e4");
278        assert_eq!(label_scientific(0.0), "0");
279        assert_eq!(label_scientific(100.0), "1e2");
280    }
281
282    #[test]
283    fn test_label_si() {
284        let f = label_si();
285        assert_eq!(f(1500.0), "1.5k");
286        assert_eq!(f(2_300_000.0), "2.3M");
287        assert_eq!(f(5e9), "5G");
288        assert_eq!(f(0.0), "0");
289        assert_eq!(f(0.0005), "500µ");
290        assert_eq!(f(-4000.0), "-4k");
291    }
292
293    #[test]
294    fn test_label_number() {
295        let f = label_number(Some(0.1), "", " kg", 1.0);
296        assert_eq!(f(4.16), "4.2 kg");
297        let pct = label_number(Some(1.0), "", "%", 100.0);
298        assert_eq!(pct(0.25), "25%");
299        let money = label_number(None, "€", "", 1.0);
300        assert_eq!(money(1500.0), "€1,500");
301    }
302
303    #[test]
304    fn label_number_is_sign_aware() {
305        let eur = label_number(None, "€", "", 1.0);
306        assert_eq!(eur(-5.0), "-€5");
307        assert_eq!(eur(-1234.5), "-€1,234.5");
308        let f = label_number(Some(1.0), "€", "", 1.0);
309        assert_eq!(f(-0.2), "€0", "rounds to zero: no sign");
310        assert_eq!(f(1_234_567.0), "€1,234,567");
311        // Thousands grouping never touches the decimals.
312        let g = label_number(Some(0.01), "", "", 1.0);
313        assert_eq!(g(1234.5), "1,234.50");
314        assert_eq!(g(-98765.4321), "-98,765.43");
315    }
316
317    #[test]
318    fn label_currency_marks_and_signs() {
319        let eur = label_currency("€", "", ".", Some(1.0));
320        assert_eq!(eur(-1_234_567.0), "-€1.234.567");
321        assert_eq!(eur(0.0), "€0");
322        let chf = label_currency("", " CHF", "'", Some(0.01));
323        assert_eq!(chf(9876.5), "9'876.50 CHF");
324        assert_eq!(chf(-0.001), "0.00 CHF");
325        let plain = label_currency("$", "", "", None);
326        assert_eq!(plain(1234.5), "$1234.5");
327        let usd = label_currency("$", "", ",", None);
328        for v in [0.0, 1.0, -500.0, 1234.5, -1_000_000.0, 0.125] {
329            assert_eq!(usd(v), label_dollar(v), "label_dollar parity at {v}");
330        }
331        assert_eq!(label_dollar(-1234.0), "-$1,234");
332        assert_eq!(usd(f64::NAN), "$NaN");
333        assert_eq!(usd(f64::NEG_INFINITY), "-$inf");
334    }
335
336    #[test]
337    fn test_label_ordinal() {
338        let f = label_ordinal();
339        assert_eq!(f(1.0), "1st");
340        assert_eq!(f(2.0), "2nd");
341        assert_eq!(f(3.0), "3rd");
342        assert_eq!(f(4.0), "4th");
343        assert_eq!(f(11.0), "11th");
344        assert_eq!(f(22.0), "22nd");
345    }
346
347    #[test]
348    fn test_label_bytes() {
349        let f = label_bytes(false);
350        assert_eq!(f(500.0), "500 B");
351        assert_eq!(f(1500.0), "1.5 kB");
352        assert_eq!(f(2_000_000.0), "2 MB");
353        let b = label_bytes(true);
354        assert_eq!(b(1024.0), "1 KiB");
355    }
356}