Skip to main content

datui_lib/widgets/
axis_numbers.rs

1//! How a chart writes its numbers: the format an axis takes from its column, the one
2//! notation and precision every tick of an axis shares, a bar's label and a readout's
3//! value, all through one writer.
4
5use crate::numfmt::{CellFormatter, NumberFormat, NumberFormatSettings};
6use polars::prelude::{AnyValue, DataType, Schema};
7
8/// Decimal places past which an axis writes its numbers in scientific notation.
9const MAX_AXIS_PLACES: i32 = 6;
10/// The most decimals a scientific mantissa takes to tell ticks apart.
11const MAX_MANTISSA_PLACES: i32 = 12;
12/// Magnitude from which an axis writes its numbers in scientific notation.
13const SCIENTIFIC_FROM: f64 = 1e15;
14
15/// What a numeric axis holds: the table's format for its numbers, and whether they are
16/// whole (counts, an integer column), which ticks the axis only at whole numbers.
17#[derive(Clone, Debug, Default, PartialEq)]
18pub struct AxisNumbers {
19    pub format: NumberFormat,
20    pub whole: bool,
21}
22
23impl AxisNumbers {
24    /// `column`'s numbers as the table prints them; plain when the schema lacks it.
25    pub fn column(settings: &NumberFormatSettings, schema: Option<&Schema>, column: &str) -> Self {
26        match schema.and_then(|s| s.get(column)) {
27            Some(dtype) => Self {
28                format: table_number_format(settings, column, dtype),
29                whole: dtype.is_integer(),
30            },
31            None => Self::default(),
32        }
33    }
34
35    /// Several columns on one axis: the first one's format, whole when every one is.
36    pub fn columns(
37        settings: &NumberFormatSettings,
38        schema: Option<&Schema>,
39        columns: &[String],
40    ) -> Self {
41        let mut each = columns.iter().map(|c| Self::column(settings, schema, c));
42        let Some(first) = each.next() else {
43            return Self::default();
44        };
45        let whole = first.whole && each.all(|n| n.whole);
46        Self { whole, ..first }
47    }
48
49    /// Counts, as the table prints a count.
50    pub fn count(settings: &NumberFormatSettings) -> Self {
51        Self {
52            format: table_number_format(settings, "Count", &DataType::UInt64),
53            whole: true,
54        }
55    }
56
57    /// A measure of the data such as a density, as the table prints a float.
58    pub fn measure(settings: &NumberFormatSettings, name: &str) -> Self {
59        Self {
60            format: table_number_format(settings, name, &DataType::Float64),
61            whole: false,
62        }
63    }
64
65    /// `v` as the table writes the column, for a readout.
66    pub fn write(&self, v: f64) -> String {
67        write(&self.format, v, Notation::Table, 0.0)
68    }
69
70    /// The same numbers, ticked anywhere: on a log scale, or spread by a density.
71    pub fn fractional(self) -> Self {
72        Self {
73            whole: false,
74            ..self
75        }
76    }
77}
78
79/// How every tick of one numeric axis is written: one notation and one precision for
80/// all of them, chosen from the ticks, in the table's grouping and decimal separator.
81/// Chosen per tick, an axis switched to scientific notation partway up.
82#[derive(Clone, Debug)]
83pub struct AxisFormat {
84    format: NumberFormat,
85    full: Notation,
86    /// The shorter form a narrow axis steps down to, when there is one.
87    short: Option<Notation>,
88    /// Below this a tick is zero: a stepped tick lands a hair off it, which
89    /// scientific notation would print as `1.32e-24`.
90    zero_below: f64,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq)]
94enum Notation {
95    /// The value in `unit`s to `places` decimals, then `suffix`: `1,234.5`, `12.3k`.
96    Fixed {
97        places: usize,
98        unit: f64,
99        suffix: &'static str,
100    },
101    /// The mantissa to `places` decimals: `1.23e-5`.
102    Scientific { places: usize },
103    /// Each value in the largest of k, M, G and T it reaches, to the fewest places
104    /// up to two that write it exactly: `500`, `2k`, `10M`. A log axis's short form,
105    /// whose ticks run across many powers of ten.
106    Prefixed,
107    /// As the table writes the column: its precision, or as Polars writes a float
108    /// (`19.434783`), never every digit an aggregate's division left.
109    Table,
110}
111
112impl AxisFormat {
113    /// The format for an axis ticked at `ticks`, in order, holding `numbers`.
114    pub fn new(ticks: &[f64], numbers: &AxisNumbers) -> Self {
115        let ticks: Vec<f64> = ticks.iter().copied().filter(|v| v.is_finite()).collect();
116        let top = ticks.iter().fold(0.0_f64, |top, v| top.max(v.abs()));
117        // The closest two ticks, which the labels must still tell apart.
118        let gap = ticks
119            .windows(2)
120            .map(|w| (w[1] - w[0]).abs())
121            .filter(|gap| *gap > 0.0)
122            .fold(f64::INFINITY, f64::min);
123        let places = if top >= SCIENTIFIC_FROM {
124            None
125        } else if numbers.whole {
126            Some(0)
127        } else {
128            fixed_places(&ticks, top, gap)
129        };
130        let (full, short) = match places {
131            Some(places) => (
132                Notation::Fixed {
133                    places,
134                    unit: 1.0,
135                    suffix: "",
136                },
137                short_notation(&ticks, top, gap),
138            ),
139            None => {
140                // Every mantissa to the places that tell the closest ticks apart.
141                let apart = if gap.is_finite() && top > 0.0 {
142                    (magnitude(top) - magnitude(gap)).clamp(0, MAX_MANTISSA_PLACES) as usize
143                } else {
144                    0
145                };
146                (
147                    Notation::Scientific {
148                        places: apart.max(2),
149                    },
150                    Some(Notation::Scientific { places: apart }),
151                )
152            }
153        };
154        Self {
155            format: numbers.format.clone(),
156            full,
157            short,
158            zero_below: if gap.is_finite() { gap * 1e-9 } else { 0.0 },
159        }
160    }
161
162    /// The format for a log axis ticked at `ticks`, values before the log: places
163    /// enough to write every tick exactly, the 0.1 and 0.25 of a short axis included,
164    /// rather than enough to tell the closest two apart, which on a log axis are the
165    /// small ones. A 1, 2 or 5 a power of ten up reads `1e18`; the short form names
166    /// each tick's own k, M, G or T: `1  10  100  1k  10k`.
167    pub fn log(ticks: &[f64], numbers: &AxisNumbers) -> Self {
168        let ticks: Vec<f64> = ticks.iter().copied().filter(|v| v.is_finite()).collect();
169        let top = ticks.iter().fold(0.0_f64, |top, v| top.max(v.abs()));
170        let full = if top >= SCIENTIFIC_FROM {
171            Notation::Scientific { places: 0 }
172        } else {
173            Notation::Fixed {
174                places: fewest_places(&ticks, 1.0, 0, MAX_AXIS_PLACES),
175                unit: 1.0,
176                suffix: "",
177            }
178        };
179        Self {
180            format: numbers.format.clone(),
181            full,
182            short: (1e3..SCIENTIFIC_FROM)
183                .contains(&top)
184                .then_some(Notation::Prefixed),
185            zero_below: 0.0,
186        }
187    }
188
189    /// The format for an axis from `lo` to `hi` ticked at its ends and halfway, as
190    /// [`crate::widgets::axes::AxisSpec::ends_and_middle`] ticks it.
191    pub fn ends_and_middle([lo, hi]: [f64; 2], numbers: &AxisNumbers) -> Self {
192        Self::new(&[lo, (lo + hi) / 2.0, hi], numbers)
193    }
194
195    /// A tick at `level` of detail: 0 the full form, 1 the short one, `None` past the
196    /// shortest.
197    pub fn label(&self, v: f64, level: usize) -> Option<String> {
198        let notation = match level {
199            0 => self.full,
200            1 => self.short?,
201            _ => return None,
202        };
203        Some(write(&self.format, v, notation, self.zero_below))
204    }
205}
206
207/// The one writer of a chart's numbers: `v` in `notation`, with `format`'s grouping
208/// and decimal separator. Below `zero_below` a scientific tick is zero.
209fn write(format: &NumberFormat, v: f64, notation: Notation, zero_below: f64) -> String {
210    let (places, unit, suffix) = match notation {
211        Notation::Scientific { places } => {
212            let v = if v.abs() < zero_below { 0.0 } else { v };
213            return scientific(v, places, format.decimal_sep);
214        }
215        Notation::Table => {
216            let mut out = String::new();
217            if v.fract() == 0.0 || format.float_precision.is_some() || !v.is_finite() {
218                format.write_f64(v, &mut String::new(), &mut out);
219            } else {
220                format.regroup_decimal(&AnyValue::Float64(v).str_value(), &mut out);
221            }
222            return out;
223        }
224        Notation::Fixed { .. } | Notation::Prefixed if !v.is_finite() => {
225            return v.to_string();
226        }
227        Notation::Prefixed => {
228            let (unit, suffix) = [(1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k")]
229                .into_iter()
230                .find(|(unit, _)| v.abs() >= *unit)
231                .unwrap_or((1.0, ""));
232            let places = fewest_places(&[v], unit, 0, 2);
233            (places, unit, suffix)
234        }
235        Notation::Fixed {
236            places,
237            unit,
238            suffix,
239        } => (places, unit, suffix),
240    };
241    let fixed = NumberFormat {
242        float_precision: Some(places as u8),
243        ..format.clone()
244    };
245    let mut out = String::new();
246    fixed.write_f64(v / unit, &mut String::new(), &mut out);
247    // A value that rounds to zero is zero: no sign, and no unit to count it in.
248    // Checked on the text, since formatting rounds -0.5 to `-0` and `round` to -1.
249    if !out.chars().any(|c| matches!(c, '1'..='9')) {
250        if unit > 1.0 {
251            return "0".to_string();
252        }
253        out.retain(|c| c != '-');
254    }
255    out.push_str(suffix);
256    out
257}
258
259/// Decimal places for `ticks`, whose largest is `top` and closest two `gap` apart:
260/// three significant figures of the largest, and enough to tell the closest apart.
261/// Fewer when they write every tick exactly, so round ticks read `20`, not `20.0`.
262/// `None` for numbers too small to write that way, or ticks too close.
263fn fixed_places(ticks: &[f64], top: f64, gap: f64) -> Option<usize> {
264    let figures = if top > 0.0 { 2 - magnitude(top) } else { 0 };
265    let apart = if gap.is_finite() { -magnitude(gap) } else { 0 };
266    let places = figures.max(apart).max(0);
267    (places <= MAX_AXIS_PLACES).then(|| fewest_places(ticks, 1.0, apart.max(0), places))
268}
269
270/// The fewest places from `least` to `most` that write every one of `ticks`, counted
271/// in `unit`s, exactly; `most` when none do.
272fn fewest_places(ticks: &[f64], unit: f64, least: i32, most: i32) -> usize {
273    let exact = |places: i32| {
274        ticks.iter().all(|v| {
275            let scaled = v / unit * 10f64.powi(places);
276            // Ticks are stepped in floating point: 0.1 * 3 is 0.30000000000000004.
277            (scaled - scaled.round()).abs() <= 1e-9 * scaled.abs().max(1.0)
278        })
279    };
280    (least..most).find(|&p| exact(p)).unwrap_or(most) as usize
281}
282
283/// The short form of `ticks`, whose largest is `top`: counted in the k, M, G or T of
284/// the largest, to two significant figures of it and places enough to tell ticks
285/// `gap` apart, at most two, or fewer as [`fixed_places`] takes them. `None` below a
286/// thousand, where there is no shorter form.
287fn short_notation(ticks: &[f64], top: f64, gap: f64) -> Option<Notation> {
288    let (unit, suffix) = [(1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k")]
289        .into_iter()
290        .find(|(unit, _)| top >= *unit)?;
291    let figures = 1 - magnitude(top / unit);
292    let apart = if gap.is_finite() {
293        -magnitude(gap / unit)
294    } else {
295        0
296    };
297    let most = figures.max(apart).clamp(0, 2);
298    Some(Notation::Fixed {
299        places: fewest_places(ticks, unit, apart.clamp(0, most), most),
300        unit,
301        suffix,
302    })
303}
304
305/// The power of ten `v` is counted in: 1 for 12.3, -2 for 0.05. A hair under a power
306/// of ten counts as it, since ticks come of floating-point arithmetic: 1000.3 less
307/// 1000.2 is 0.09999... and not a tenth.
308fn magnitude(v: f64) -> i32 {
309    (v.log10() + 1e-9).floor() as i32
310}
311
312/// `v` in scientific notation, its mantissa to `places` decimals.
313fn scientific(v: f64, places: usize, decimal_sep: char) -> String {
314    // No `-0.00e0`.
315    let v = if v == 0.0 { 0.0 } else { v };
316    let text = format!("{v:.places$e}");
317    if decimal_sep == '.' {
318        text
319    } else {
320        text.replacen('.', decimal_sep.encode_utf8(&mut [0; 4]), 1)
321    }
322}
323
324/// The format the table prints `column` in, plain where it prints it unformatted.
325pub fn table_number_format(
326    settings: &NumberFormatSettings,
327    column: &str,
328    dtype: &DataType,
329) -> NumberFormat {
330    match settings.formatter_for(column, dtype) {
331        CellFormatter::Number(format) => format,
332        CellFormatter::Passthrough => NumberFormat::PLAIN,
333    }
334}
335
336/// Format a bar's value for the label beside it in `format`: an integer column's whole,
337/// anything else to the format's decimal places or two, so every bar shows the same
338/// number of them. Values too large, or too small to show in those places, go to
339/// scientific notation.
340pub fn format_bar_value(v: f64, integer: bool, format: &NumberFormat) -> String {
341    let places = format.float_precision.unwrap_or(2);
342    let smallest = 0.5 * 10f64.powi(-i32::from(places));
343    let notation =
344        if !v.is_finite() || v.abs() >= 1e15 || (!integer && v != 0.0 && v.abs() < smallest) {
345            Notation::Scientific { places: 2 }
346        } else {
347            Notation::Fixed {
348                places: if integer { 0 } else { places.into() },
349                unit: 1.0,
350                suffix: "",
351            }
352        };
353    write(format, v, notation, 0.0)
354}
355
356#[cfg(test)]
357mod tests;