Skip to main content

data_beans/qc/
axis.rs

1//! Where values sit across a histogram, and its bins: linear, or log on
2//! `ln(shift + x)`, the shift chosen from the data so zeros have a place
3//! and values mostly below 1 (means, fractions) spread out. The printed
4//! histograms, the views' and the figures' all read values on it.
5
6/// Where values sit across a panel: linear, or log on `ln(shift + x)`.
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct Axis {
9    pub lo: f64,
10    pub hi: f64,
11    pub log: bool,
12    /// What a log axis adds before taking the log: 1 for counts, so zero
13    /// has a place; less for values mostly below 1 (means, fractions).
14    pub shift: f64,
15}
16
17impl Axis {
18    pub fn linear(lo: f64, hi: f64) -> Self {
19        Self {
20            lo,
21            hi,
22            log: false,
23            shift: 0.0,
24        }
25    }
26
27    /// A log axis on `ln(1 + x)`.
28    pub fn log1p(lo: f64, hi: f64) -> Self {
29        Self {
30            lo,
31            hi,
32            log: true,
33            shift: 1.0,
34        }
35    }
36
37    /// The finite range of `values`, widened where they are all one value.
38    /// A log axis shifts by 1 where the values reach 20, else by the
39    /// smallest positive value's half, so small ones spread out too.
40    pub fn fit(values: &[f32], log: bool) -> Self {
41        let (mut lo, mut hi) = values
42            .iter()
43            .filter(|v| v.is_finite())
44            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
45                (lo.min(v as f64), hi.max(v as f64))
46            });
47        if !lo.is_finite() {
48            (lo, hi) = (0.0, 1.0);
49        }
50        if log {
51            lo = lo.max(0.0);
52        }
53        if hi <= lo {
54            hi = lo + if log { lo.max(1.0) } else { 1.0 };
55        }
56        let shift = if !log || hi >= 20.0 {
57            1.0
58        } else {
59            let least = values
60                .iter()
61                .filter(|v| v.is_finite() && **v > 0.0)
62                .fold(f64::INFINITY, |m, &v| m.min(v as f64));
63            (least / 2.0).clamp(hi * 1e-4, 1.0)
64        };
65        Self { lo, hi, log, shift }
66    }
67
68    /// This axis reaching `v` too.
69    pub fn including(mut self, v: f64) -> Self {
70        if v.is_finite() {
71            self.lo = self.lo.min(if self.log { v.max(0.0) } else { v });
72            self.hi = self.hi.max(v);
73        }
74        self
75    }
76
77    fn t(&self, v: f64) -> f64 {
78        if self.log {
79            (v.max(0.0) + self.shift).ln()
80        } else {
81            v
82        }
83    }
84
85    /// Where `v` falls, 0 at `lo` and 1 at `hi`.
86    pub fn frac(&self, v: f64) -> f64 {
87        let (a, b) = (self.t(self.lo), self.t(self.hi));
88        if b > a {
89            (self.t(v) - a) / (b - a)
90        } else {
91            0.0
92        }
93    }
94
95    /// The value at `f` of the way from `lo` to `hi`.
96    pub fn at(&self, f: f64) -> f64 {
97        let (a, b) = (self.t(self.lo), self.t(self.hi));
98        let t = a + f * (b - a);
99        if self.log {
100            (t.exp() - self.shift).max(0.0)
101        } else {
102            t
103        }
104    }
105
106    /// The bin of `n` that `v` falls in, clamped to the range.
107    pub fn bin(&self, v: f64, n: usize) -> usize {
108        ((self.frac(v) * n as f64).floor().max(0.0) as usize).min(n.saturating_sub(1))
109    }
110
111    /// How many of `values` (those `pick` takes) fall in each of `n` bins.
112    pub fn counts(&self, values: &[f32], n: usize, pick: impl Fn(usize) -> bool) -> Vec<usize> {
113        let mut out = vec![0; n];
114        for (i, &v) in values.iter().enumerate() {
115            if v.is_finite() && pick(i) {
116                out[self.bin(v as f64, n)] += 1;
117            }
118        }
119        out
120    }
121
122    /// Tick values inside the range: 1-2-5 steps on a log axis (and 0 where
123    /// it starts there), round steps on a linear one.
124    pub fn ticks(&self) -> Vec<f64> {
125        if self.log {
126            let mut t: Vec<f64> = (self.lo <= 0.0).then_some(0.0).into_iter().collect();
127            for e in -3..10 {
128                for m in [1.0, 2.0, 5.0] {
129                    let v = m * 10f64.powi(e);
130                    if v >= self.lo && v <= self.hi {
131                        t.push(v);
132                    }
133                }
134            }
135            // On ln(shift + x) the small ones crowd at the start: keep a tick
136            // only a twentieth of the axis past the last one kept.
137            let mut kept: Vec<f64> = vec![];
138            for v in t {
139                if kept
140                    .last()
141                    .is_none_or(|&l| self.frac(v) - self.frac(l) >= 0.05)
142                {
143                    kept.push(v);
144                }
145            }
146            let mut t = kept;
147            // Thin to at most 8, keeping the decades.
148            while t.len() > 8 {
149                let decade = |v: &f64| *v == 0.0 || (v.log10() - v.log10().round()).abs() < 1e-9;
150                if t.iter().all(decade) {
151                    t = t.into_iter().step_by(2).collect();
152                } else {
153                    t.retain(decade);
154                }
155            }
156            t
157        } else {
158            let span = self.hi - self.lo;
159            let raw = span / 5.0;
160            let mag = 10f64.powf(raw.log10().floor());
161            let step = [1.0, 2.0, 2.5, 5.0, 10.0]
162                .iter()
163                .map(|m| m * mag)
164                .find(|s| span / s <= 6.0)
165                .unwrap_or(10.0 * mag);
166            // Each tick k steps in, rounded to the step's decimals so 0.15
167            // reads 0.15.
168            let places = 10f64.powi((2 - step.log10().floor() as i32).max(0));
169            let first = (self.lo / step).ceil() as i64;
170            (first..)
171                .map(|k| (k as f64 * step * places).round() / places)
172                .take_while(|v| *v <= self.hi + 1e-9 * step)
173                .map(|v| if v == 0.0 { 0.0 } else { v })
174                .collect()
175        }
176    }
177}