Skip to main content

densa_chart/
axis.rs

1use std::fmt::Write as _;
2
3use densa_fmt::{civil_from_days, write_fixed, FmtBuf, FmtError};
4
5/// What an axis's values are, which decides where its ticks go and how they read.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum AxisKind {
9    /// Plain numbers: ticks at 1, 2 or 5 times a power of ten.
10    Number,
11    /// Seconds since 1970-01-01 UTC: ticks at whole seconds, minutes, hours or days,
12    /// labelled as times of day or dates (in UTC; there is no time-zone database).
13    Time,
14}
15
16/// The values one axis of a plot spans, `start` below `end`.
17///
18/// Built from any two values: they are put in order, a single value is widened around
19/// itself (a flat series still gets an axis), and non-finite ones give `0..1`.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct Axis {
22    start: f64,
23    end: f64,
24    kind: AxisKind,
25}
26
27/// Seconds in a day.
28const DAY: f64 = 86_400.0;
29
30/// Tick steps of a time axis, in seconds, from one second to a day; finer and coarser
31/// steps are 1, 2 or 5 times a power of ten (of seconds, or of days).
32const TIME_STEPS: [f64; 18] = [
33    1.0, 2.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0, 900.0, 1_800.0, 3_600.0, 7_200.0,
34    10_800.0, 21_600.0, 43_200.0, DAY,
35];
36
37/// Tick indices past this cannot be told apart in an `f64` value.
38const MAX_TICK_INDEX: f64 = 9_007_199_254_740_992.0;
39
40impl Axis {
41    /// An axis of numbers from `start` to `end`.
42    pub fn number(start: f64, end: f64) -> Self {
43        Self::new(start, end, AxisKind::Number)
44    }
45
46    /// An axis of times from `start` to `end`, in seconds since 1970-01-01 UTC.
47    pub fn time(start: f64, end: f64) -> Self {
48        Self::new(start, end, AxisKind::Time)
49    }
50
51    /// An axis of `kind` from `start` to `end`.
52    pub fn new(start: f64, end: f64, kind: AxisKind) -> Self {
53        let (start, end) = if start.is_finite() && end.is_finite() {
54            // Halved so the span itself stays finite.
55            let limit = f64::MAX / 2.0;
56            let (low, high) = (start.min(end), start.max(end));
57            (low.clamp(-limit, limit), high.clamp(-limit, limit))
58        } else {
59            (0.0, 1.0)
60        };
61        let (start, end) = if end > start {
62            (start, end)
63        } else {
64            let half = if start == 0.0 {
65                1.0
66            } else {
67                (start.abs() * 0.01).max(f64::MIN_POSITIVE)
68            };
69            (start - half, start + half)
70        };
71        Self { start, end, kind }
72    }
73
74    /// The first value.
75    pub fn start(&self) -> f64 {
76        self.start
77    }
78
79    /// The last value.
80    pub fn end(&self) -> f64 {
81        self.end
82    }
83
84    /// `end - start`, always above zero.
85    pub fn span(&self) -> f64 {
86        self.end - self.start
87    }
88
89    /// What the values are.
90    pub fn kind(&self) -> AxisKind {
91        self.kind
92    }
93
94    /// The axis grown by `fraction` of its span at each end: room around a series so it
95    /// does not touch the plot's edges.
96    #[must_use]
97    pub fn padded(self, fraction: f64) -> Self {
98        let pad = self.span() * fraction.max(0.0);
99        Self::new(self.start - pad, self.end + pad, self.kind)
100    }
101
102    /// The axis moved by `delta` (panning).
103    #[must_use]
104    pub fn shifted(self, delta: f64) -> Self {
105        Self::new(self.start + delta, self.end + delta, self.kind)
106    }
107
108    /// The axis scaled by `factor` around `anchor`, which stays where it is: below 1 zooms
109    /// in, above 1 out. A factor that is not above zero changes nothing.
110    #[must_use]
111    pub fn zoomed(self, factor: f64, anchor: f64) -> Self {
112        if !(factor > 0.0 && factor.is_finite() && anchor.is_finite()) {
113            return self;
114        }
115        Self::new(
116            anchor - (anchor - self.start) * factor,
117            anchor + (self.end - anchor) * factor,
118            self.kind,
119        )
120    }
121
122    /// Where `value` falls: 0 at `start`, 1 at `end`, beyond those outside the axis.
123    pub fn fraction(&self, value: f64) -> f64 {
124        (value - self.start) / self.span()
125    }
126
127    /// The value at `fraction` of the way from `start` to `end`.
128    pub fn value_at(&self, fraction: f64) -> f64 {
129        self.start + fraction * self.span()
130    }
131
132    /// Evenly spaced round values within the axis, at most `max + 1` of them, ready to be
133    /// labelled ([`Ticks::write_label`]).
134    pub fn ticks(&self, max: usize) -> Ticks {
135        // Lossless enough: a tick count is small.
136        let raw = self.span() / max.max(1) as f64;
137        let step = match self.kind {
138            AxisKind::Number => nice_step(raw),
139            AxisKind::Time if raw < 1.0 => nice_step(raw),
140            AxisKind::Time if raw > DAY => nice_step(raw / DAY) * DAY,
141            AxisKind::Time => TIME_STEPS
142                .iter()
143                .copied()
144                .find(|&step| step >= raw)
145                .unwrap_or(DAY),
146        };
147        let first = (self.start / step).ceil();
148        let last = (self.end / step).floor();
149        let usable = step.is_finite() && step > 0.0;
150        let (next, last) = if usable && first.abs() < MAX_TICK_INDEX && last.abs() < MAX_TICK_INDEX
151        {
152            // Lossless: both are whole numbers below 2^53.
153            (first as i64, last as i64)
154        } else {
155            (1, 0)
156        };
157        Ticks {
158            next,
159            last,
160            step,
161            kind: self.kind,
162        }
163    }
164}
165
166/// The smallest of 1, 2, 5 and 10 times a power of ten that is at least `raw`.
167fn nice_step(raw: f64) -> f64 {
168    let magnitude = 10f64.powf(raw.log10().floor());
169    let norm = raw / magnitude;
170    let multiple = if norm <= 1.0 {
171        1.0
172    } else if norm <= 2.0 {
173        2.0
174    } else if norm <= 5.0 {
175        5.0
176    } else {
177        10.0
178    };
179    multiple * magnitude
180}
181
182/// The ticks of an [`Axis`], smallest first: whole multiples of [`step`](Self::step).
183#[derive(Debug, Clone)]
184pub struct Ticks {
185    next: i64,
186    last: i64,
187    step: f64,
188    kind: AxisKind,
189}
190
191impl Ticks {
192    /// The distance between two ticks.
193    pub fn step(&self) -> f64 {
194        self.step
195    }
196
197    /// Appends the label of tick `value`, as precise as the step needs: `0.25` for steps of
198    /// 0.05, `14:30` for steps of minutes, `2026-10-19` for steps of days. All or nothing,
199    /// like every `densa_fmt` write.
200    ///
201    /// # Errors
202    /// [`FmtError::Overflow`] if the label does not fit, [`FmtError::NotFinite`] for a
203    /// value that is not finite.
204    pub fn write_label<const N: usize>(
205        &self,
206        value: f64,
207        text: &mut FmtBuf<N>,
208    ) -> Result<(), FmtError> {
209        if !value.is_finite() {
210            return Err(FmtError::NotFinite);
211        }
212        match self.kind {
213            AxisKind::Number => {
214                let digits = decimals(self.step);
215                // Lossless: at most 15 digits.
216                let scale = 10f64.powi(digits as i32);
217                // A value that rounds to zero is written without a sign: "0.00", not "-0.00".
218                let value = if (value * scale).round() == 0.0 {
219                    0.0
220                } else {
221                    value
222                };
223                write_fixed(text, value, digits)
224            }
225            AxisKind::Time => {
226                let mark = text.len();
227                write_time(text, value, self.step).map_err(|_| {
228                    text.truncate(mark);
229                    FmtError::Overflow
230                })
231            }
232        }
233    }
234}
235
236impl Iterator for Ticks {
237    type Item = f64;
238
239    fn next(&mut self) -> Option<f64> {
240        if self.next > self.last {
241            return None;
242        }
243        // Lossless: tick indices stay below 2^53.
244        let value = self.next as f64 * self.step;
245        self.next += 1;
246        Some(value)
247    }
248
249    fn size_hint(&self) -> (usize, Option<usize>) {
250        let left = usize::try_from(self.last - self.next + 1).unwrap_or(0);
251        (left, Some(left))
252    }
253}
254
255impl ExactSizeIterator for Ticks {}
256
257/// Fraction digits a label needs to tell ticks `step` apart (at most 15).
258fn decimals(step: f64) -> usize {
259    // The epsilon keeps exact powers of ten (0.1) from asking for one digit too many.
260    let digits = (-step.log10() - 1e-9).ceil();
261    // Lossless: clamped to 0..=15.
262    digits.clamp(0.0, 15.0) as usize
263}
264
265/// A time label for a tick `step` apart from its neighbours: `HH:MM:SS.fff` below a
266/// second, `HH:MM:SS` below a minute, `HH:MM` below a day, `YYYY-MM-DD` from a day.
267fn write_time<const N: usize>(text: &mut FmtBuf<N>, value: f64, step: f64) -> std::fmt::Result {
268    if step >= DAY {
269        // Lossless enough: dates within the range `civil_from_days` handles.
270        let days = (value / DAY).floor() as i64;
271        let (year, month, day) = civil_from_days(days);
272        let sign = if year < 0 { "-" } else { "" };
273        let digits = year.unsigned_abs();
274        return write!(text, "{sign}{digits:04}-{month:02}-{day:02}");
275    }
276    let digits = if step < 1.0 { decimals(step).min(9) } else { 0 };
277    // Lossless: digits is at most 9.
278    let scale = 10i64.pow(digits as u32);
279    // Lossless: whole units of the last digit, checked to fit an i64 below.
280    let units = (value * scale as f64).round();
281    if units.abs() >= 9.0e18 {
282        return Err(std::fmt::Error);
283    }
284    let units = units as i64;
285    let seconds = units.div_euclid(scale).rem_euclid(86_400);
286    let fraction = units.rem_euclid(scale);
287    let (hours, minutes, secs) = (seconds / 3_600, seconds / 60 % 60, seconds % 60);
288    write!(text, "{hours:02}:{minutes:02}")?;
289    if step < 60.0 {
290        write!(text, ":{secs:02}")?;
291    }
292    if digits > 0 {
293        write!(text, ".{fraction:0digits$}")?;
294    }
295    Ok(())
296}