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