Skip to main content

ravenrs_sheet/chart/
axis.rs

1//! Value-axis scaling ("nice numbers") and label formatting.
2
3use crate::base::{DateSystem, Value, number_to_text};
4use crate::numfmt::{NumberFormat, format_value};
5
6/// Largest magnitude we plot; bigger values are clamped so ranges stay finite.
7pub(crate) const MAX_ABS: f64 = 1e300;
8
9/// A linear axis scale: `min..=max` with gridlines every `step`.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct Scale {
12    pub min: f64,
13    pub max: f64,
14    pub step: f64,
15}
16
17impl Scale {
18    /// Tick values from `min` to `max` (at most 51).
19    pub fn ticks(&self) -> Vec<f64> {
20        let span = self.max - self.min;
21        if !(span.is_finite() && self.step.is_finite() && self.step > 0.0 && span > 0.0) {
22            return vec![self.min];
23        }
24        let n = ((span / self.step).round() as i64).clamp(1, 50);
25        (0..=n)
26            .map(|i| {
27                let v = self.min + i as f64 * self.step;
28                // Snap away accumulated float error, but only onto a multiple of the step that is
29                // really that close: a `min` off the step grid must keep its own ticks.
30                let snapped = (v / self.step).round() * self.step;
31                let v = if (snapped - v).abs() < self.step * 1e-9 { snapped } else { v };
32                if v.abs() < self.step * 1e-9 { 0.0 } else { v }
33            })
34            .collect()
35    }
36    /// Position of `v` along the axis, 0 at `min`, 1 at `max` (clamped to a sane band).
37    pub fn frac(&self, v: f64) -> f32 {
38        let span = self.max - self.min;
39        if !(span.is_finite() && span > 0.0 && v.is_finite()) {
40            return 0.0;
41        }
42        (((v - self.min) / span).clamp(-0.5, 1.5)) as f32
43    }
44}
45
46pub(crate) fn clean(v: f64) -> f64 {
47    if v.is_finite() { v.clamp(-MAX_ABS, MAX_ABS) } else { 0.0 }
48}
49
50/// Picks a "nice" scale (steps of 1, 2 or 5 × 10ⁿ, 4–9 gridlines) covering `lo..=hi`.
51///
52/// `force_zero` always includes 0 (columns, bars, areas). Otherwise 0 is still included when the
53/// data sits reasonably close to it (min below 5/6 of max), like common spreadsheet behaviour.
54/// A little headroom is added past the data so the extreme value doesn't touch the frame.
55pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale {
56    let (mut lo, mut hi) = (clean(lo), clean(hi));
57    if lo > hi {
58        std::mem::swap(&mut lo, &mut hi);
59    }
60    if force_zero || (lo > 0.0 && lo < hi * 5.0 / 6.0) || lo == hi {
61        lo = lo.min(0.0);
62    }
63    if force_zero || (hi < 0.0 && hi > lo * 5.0 / 6.0) || lo == hi {
64        hi = hi.max(0.0);
65    }
66    if lo == hi {
67        // Both zero.
68        return Scale { min: 0.0, max: 1.0, step: 0.2 };
69    }
70    let range = hi - lo;
71    if hi > 0.0 {
72        hi += range * 0.05;
73    }
74    if lo < 0.0 {
75        lo -= range * 0.05;
76    }
77    let range = hi - lo;
78    if !range.is_finite() || range <= 0.0 {
79        return Scale { min: 0.0, max: 1.0, step: 0.2 };
80    }
81    let e = (range / 8.0).log10().floor();
82    let mut chosen = None;
83    'outer: for k in [e - 1.0, e, e + 1.0, e + 2.0] {
84        let p = 10f64.powf(k);
85        for m in [1.0, 2.0, 5.0] {
86            let s = m * p;
87            if !(s.is_finite() && s > 0.0) {
88                continue;
89            }
90            let intervals = (hi / s - 1e-9).ceil() - (lo / s + 1e-9).floor();
91            if intervals <= 8.0 {
92                chosen = Some(s);
93                break 'outer;
94            }
95        }
96    }
97    let step = chosen.unwrap_or(range);
98    let mut min = (lo / step + 1e-9).floor() * step;
99    let mut max = (hi / step - 1e-9).ceil() * step;
100    if min.abs() < step * 1e-9 {
101        min = 0.0;
102    }
103    if max.abs() < step * 1e-9 {
104        max = 0.0;
105    }
106    if !(min.is_finite() && max.is_finite()) || max <= min {
107        return Scale { min: lo, max: hi, step: range };
108    }
109    Scale { min, max, step }
110}
111
112/// A number format prepared once and reused for many labels.
113pub(crate) struct Fmt {
114    general: bool,
115    fmt: NumberFormat,
116}
117
118impl Fmt {
119    pub fn new(code: &str) -> Fmt {
120        let t = code.trim();
121        let general = t.is_empty() || t.eq_ignore_ascii_case("general");
122        Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) }
123    }
124    /// Formats `v`; `step` (axis labels) fixes the decimals for General.
125    pub fn format(&self, v: f64, step: Option<f64>) -> String {
126        let mut v = clean(v);
127        if let Some(s) = step
128            && s > 0.0
129            && (v / s).abs() < 1e-9
130        {
131            v = 0.0;
132        }
133        if self.general {
134            return general_text(v, step);
135        }
136        let mut t = format_value(&Value::Number(v), &self.fmt, DateSystem::D1900).text;
137        if t.chars().count() > 40 {
138            t = t.chars().take(40).collect();
139        }
140        t
141    }
142}
143
144fn general_text(v: f64, step: Option<f64>) -> String {
145    if v == 0.0 {
146        return "0".into();
147    }
148    match step {
149        Some(s) if s.is_finite() && s > 0.0 && v.abs() < 1e11 && v.abs() >= 1e-9 => {
150            let d = (-s.log10().floor()).clamp(0.0, 10.0) as usize;
151            let t = format!("{:.*}", d, v);
152            if t.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { t }
153        }
154        _ => number_to_text(v),
155    }
156}
157
158/// Formats a number with a format code (General shows up to the precision implied by `step`).
159pub fn format_number(v: f64, code: &str, step: Option<f64>) -> String {
160    Fmt::new(code).format(v, step)
161}