Skip to main content

datui_lib/widgets/
ticks.rs

1//! Where an axis's ticks fall: on nice numbers, 1, 2 or 5 times a power of ten, or
2//! on calendar boundaries such as hours, days, months and years. Pure arithmetic;
3//! [`crate::widgets::axes`] decides how many fit and draws them.
4
5use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, Timelike};
6
7use crate::chart::chart_data::{XAxisTemporalKind, x_datetime, x_time};
8
9/// More ticks than this in one set is a step too fine to consider.
10const MOST_TICKS: f64 = 4096.0;
11
12/// The 1-2-5 steps for an axis from `lo` to `hi`, finest first: from the finest no
13/// finer than `finest` to the first that spans the whole axis. 25, 250 and so on
14/// step between 20 and 50 where they need no more places than their neighbors. A
15/// `whole` axis steps by whole numbers only.
16pub fn nice_steps(lo: f64, hi: f64, finest: f64, whole: bool) -> Vec<f64> {
17    let span = hi - lo;
18    if !(span > 0.0 && span.is_finite()) {
19        return Vec::new();
20    }
21    let finest = finest.max(span / MOST_TICKS);
22    let mut exp = finest.log10().floor() as i32;
23    let mut steps = Vec::new();
24    loop {
25        for m in [1.0, 2.0, 2.5, 5.0] {
26            let step = m * 10f64.powi(exp);
27            if step < finest * (1.0 - 1e-9) || (whole && step < 1.0) || (m == 2.5 && exp < 1) {
28                continue;
29            }
30            steps.push(step);
31            if step >= span {
32                return steps;
33            }
34        }
35        exp += 1;
36    }
37}
38
39/// `v` rounded to the places `step` is written in, so 0.1 * 3 reads 0.3.
40fn clean(v: f64, step: f64) -> f64 {
41    let places = (-(step.log10() + 1e-9).floor()).max(0.0) as i32 + 1;
42    let scale = 10f64.powi(places.min(15));
43    let rounded = (v * scale).round() / scale;
44    // No -0.
45    if rounded == 0.0 { 0.0 } else { rounded }
46}
47
48/// The multiples of `step` from `lo` to `hi`.
49pub fn multiples(lo: f64, hi: f64, step: f64) -> Vec<f64> {
50    if step.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) || (hi - lo) / step > MOST_TICKS
51    {
52        return Vec::new();
53    }
54    let first = (lo / step - 1e-9).ceil() as i64;
55    let last = (hi / step + 1e-9).floor() as i64;
56    (first..=last)
57        .map(|k| clean(k as f64 * step, step))
58        .collect()
59}
60
61/// `lo` to `hi` widened out to multiples of `step`, never to nothing.
62pub fn widen(lo: f64, hi: f64, step: f64) -> [f64; 2] {
63    let wlo = clean((lo / step + 1e-9).floor() * step, step);
64    let mut whi = clean((hi / step - 1e-9).ceil() * step, step);
65    if whi <= wlo {
66        whi = clean(wlo + step, step);
67    }
68    [wlo, whi]
69}
70
71/// [`widen`], except that an end a whole tick would leave more than three quarters of
72/// a step empty stops at the coarsest minor tick past the data instead. A mean a hair
73/// under zero widened a step-10 axis down to -10, a fifth of the plot holding nothing.
74pub fn widen_snug(lo: f64, hi: f64, step: f64, whole: bool) -> [f64; 2] {
75    let [wlo, whi] = widen(lo, hi, step);
76    let Some(&minor) = minor_steps(step, whole).last() else {
77        return [wlo, whi];
78    };
79    let [mlo, mhi] = widen(lo, hi, minor);
80    let most = step * 0.75;
81    [
82        if lo - wlo > most { mlo } else { wlo },
83        if whi - hi > most { mhi } else { whi },
84    ]
85}
86
87/// A finer step that ticks between those of `step`, 1-2-5 as well: tenths of a
88/// 1-step, fifths, halves; halves or quarters of a 2-step; fifths of a 5-step.
89/// Finest first.
90pub fn minor_steps(step: f64, whole: bool) -> Vec<f64> {
91    let mantissa = step / 10f64.powf((step.log10() + 1e-9).floor());
92    let divisors: &[f64] = if (mantissa - 1.0).abs() < 1e-6 {
93        &[5.0, 2.0]
94    } else if (mantissa - 2.0).abs() < 1e-6 {
95        &[4.0, 2.0]
96    } else {
97        // Fifths of a 5-step, and of a 25-step.
98        &[5.0]
99    };
100    divisors
101        .iter()
102        .map(|d| step / d)
103        .filter(|s| !whole || *s >= 1.0 - 1e-9)
104        .collect()
105}
106
107/// A calendar unit a time axis ticks on.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
109pub enum Unit {
110    Second,
111    Minute,
112    Hour,
113    Day,
114    Month,
115    Year,
116}
117
118impl Unit {
119    /// About how long one is, in seconds.
120    fn seconds(self) -> f64 {
121        match self {
122            Unit::Second => 1.0,
123            Unit::Minute => 60.0,
124            Unit::Hour => 3600.0,
125            Unit::Day => 86_400.0,
126            Unit::Month => 2_629_746.0,
127            Unit::Year => 31_556_952.0,
128        }
129    }
130}
131
132/// Every `n` of a unit. Each step divides the unit above it, so a new day, month or
133/// year always lands on a tick.
134#[derive(Clone, Copy, Debug, PartialEq)]
135pub struct CalendarStep {
136    pub unit: Unit,
137    pub n: u32,
138}
139
140const CALENDAR_STEPS: &[(Unit, &[u32])] = &[
141    (Unit::Second, &[1, 2, 5, 10, 15, 30]),
142    (Unit::Minute, &[1, 2, 5, 10, 15, 30]),
143    (Unit::Hour, &[1, 2, 3, 6, 12]),
144    // Counted from the 1st of each month: 1, 8, 15, 22.
145    (Unit::Day, &[1, 2, 7, 14]),
146    (Unit::Month, &[1, 2, 3, 6]),
147    (Unit::Year, &[1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]),
148];
149
150/// The calendar steps a `kind` axis can take, finest first: a date ticks on days at
151/// the finest, a time of day on hours at the coarsest.
152pub fn calendar_steps(kind: XAxisTemporalKind) -> impl Iterator<Item = CalendarStep> {
153    CALENDAR_STEPS
154        .iter()
155        .flat_map(|(unit, ns)| ns.iter().map(move |&n| CalendarStep { unit: *unit, n }))
156        .filter(move |s| match kind {
157            XAxisTemporalKind::Date => s.unit >= Unit::Day,
158            XAxisTemporalKind::Time => s.unit <= Unit::Hour,
159            _ => true,
160        })
161}
162
163/// An axis value as the date and time it stands for; a time of day falls on the
164/// epoch's day.
165pub fn to_datetime(v: f64, kind: XAxisTemporalKind) -> Option<NaiveDateTime> {
166    match kind {
167        XAxisTemporalKind::Numeric => None,
168        XAxisTemporalKind::Time => Some(epoch_day().and_time(x_time(v)?)),
169        _ => x_datetime(v, kind),
170    }
171}
172
173fn epoch_day() -> NaiveDate {
174    NaiveDate::from_ymd_opt(1970, 1, 1).unwrap_or_default()
175}
176
177/// The axis value a date and time stands at, the inverse of [`to_datetime`].
178pub fn from_datetime(at: NaiveDateTime, kind: XAxisTemporalKind) -> Option<f64> {
179    let utc = at.and_utc();
180    Some(match kind {
181        XAxisTemporalKind::Numeric => return None,
182        XAxisTemporalKind::Date => (at.date() - epoch_day()).num_days() as f64,
183        XAxisTemporalKind::DatetimeUs => utc.timestamp_micros() as f64,
184        XAxisTemporalKind::DatetimeMs => utc.timestamp_millis() as f64,
185        XAxisTemporalKind::DatetimeNs => utc.timestamp_nanos_opt()? as f64,
186        XAxisTemporalKind::Time => {
187            let t = at.time();
188            f64::from(t.num_seconds_from_midnight()) * 1e9 + f64::from(t.nanosecond())
189        }
190    })
191}
192
193/// The ticks of `step` from `lo` to `hi`, on its boundaries; none when there would
194/// be too many to consider.
195pub fn calendar_ticks(
196    lo: NaiveDateTime,
197    hi: NaiveDateTime,
198    step: CalendarStep,
199) -> Vec<NaiveDateTime> {
200    let span = (hi - lo).as_seconds_f64();
201    let n = step.n.max(1);
202    if span < 0.0 || span / (step.unit.seconds() * f64::from(n)) > MOST_TICKS {
203        return Vec::new();
204    }
205    let midnight = |d: NaiveDate| d.and_time(NaiveTime::MIN);
206    let mut ticks = Vec::new();
207    let mut push = |at: NaiveDateTime| {
208        if at >= lo && at <= hi {
209            ticks.push(at);
210        }
211        at <= hi
212    };
213    match step.unit {
214        Unit::Year => {
215            let n = n as i32;
216            let mut year = lo.year().div_euclid(n) * n;
217            while let Some(d) = NaiveDate::from_ymd_opt(year, 1, 1) {
218                if !push(midnight(d)) {
219                    break;
220                }
221                year += n;
222            }
223        }
224        Unit::Month => {
225            let n = n as i32;
226            let first = lo.year() * 12 + lo.month0() as i32;
227            let mut month = first - first.rem_euclid(n);
228            while let Some(d) =
229                NaiveDate::from_ymd_opt(month.div_euclid(12), month.rem_euclid(12) as u32 + 1, 1)
230            {
231                if !push(midnight(d)) {
232                    break;
233                }
234                month += n;
235            }
236        }
237        Unit::Day if n == 1 => {
238            let mut day = lo.date();
239            while push(midnight(day)) {
240                let Some(next) = day.succ_opt() else { break };
241                day = next;
242            }
243        }
244        Unit::Day => {
245            // From the 1st of each month, so every month starts on a tick; the last
246            // gap of a month runs a little long.
247            let last = if n == 2 { 29 } else { 28 };
248            let (mut year, mut month) = (lo.year(), lo.month());
249            'months: while let Some(first) = NaiveDate::from_ymd_opt(year, month, 1) {
250                for day in (1..=last).step_by(n as usize) {
251                    if let Some(d) = first.with_day(day)
252                        && !push(midnight(d))
253                    {
254                        break 'months;
255                    }
256                }
257                (year, month) = if month == 12 {
258                    (year + 1, 1)
259                } else {
260                    (year, month + 1)
261                };
262            }
263        }
264        Unit::Hour | Unit::Minute | Unit::Second => {
265            // Each step divides a day, so counting from midnight keeps them on the
266            // hour, the quarter hour, the minute.
267            let every = step.unit.seconds() as i64 * i64::from(n);
268            let day = midnight(lo.date());
269            let into = (lo - day).num_seconds();
270            let mut at = day + TimeDelta::seconds(into.div_euclid(every) * every);
271            while push(at) {
272                at += TimeDelta::seconds(every);
273            }
274        }
275    }
276    ticks
277}
278
279/// Labels for ticks on `unit` boundaries, each naming the coarsest unit that turns
280/// there: `2026` at a new year, `Apr` at a new month, `Mar 5` at a new day among
281/// hours, otherwise the tick in its own unit (`12`, `06:00`). With `context` the
282/// first tick also names the year (and the day, for hours) the axis starts in. A
283/// time of day reads as a clock.
284pub fn calendar_labels(
285    ticks: &[NaiveDateTime],
286    unit: Unit,
287    kind: XAxisTemporalKind,
288    context: bool,
289) -> Vec<String> {
290    ticks
291        .iter()
292        .enumerate()
293        .map(|(i, at)| {
294            let pattern = if kind == XAxisTemporalKind::Time {
295                if unit == Unit::Second {
296                    "%H:%M:%S"
297                } else {
298                    "%H:%M"
299                }
300            } else {
301                calendar_pattern(at, unit, context && i == 0)
302            };
303            at.format(pattern).to_string()
304        })
305        .collect()
306}
307
308fn calendar_pattern(at: &NaiveDateTime, unit: Unit, first: bool) -> &'static str {
309    let day_start = at.time() == NaiveTime::MIN;
310    let month_start = day_start && at.day() == 1;
311    let year_start = month_start && at.month() == 1;
312    if year_start || unit == Unit::Year {
313        return "%Y";
314    }
315    match unit {
316        Unit::Month if first => "%b %Y",
317        Unit::Month => "%b",
318        Unit::Day if first => "%b %-d %Y",
319        _ if month_start && !first => "%b",
320        Unit::Day => "%-d",
321        _ if day_start && first => "%b %-d %Y",
322        _ if day_start => "%b %-d",
323        Unit::Second if first => "%b %-d %Y %H:%M:%S",
324        Unit::Second => "%H:%M:%S",
325        _ if first => "%b %-d %Y %H:%M",
326        _ => "%H:%M",
327    }
328}
329
330#[cfg(test)]
331mod tests;