Skip to main content

ggplot_rs/scale/
datetime.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::Scale;
5
6/// A break interval for a date/time axis.
7#[derive(Clone, Copy, Debug)]
8enum DateBreak {
9    /// A fixed number of seconds (seconds/minutes/hours/days/weeks).
10    Secs(f64),
11    /// A number of whole calendar months (years = 12 × n).
12    Months(u32),
13}
14
15/// Date/time scale — maps epoch seconds to [0, 1] and formats axis labels as dates.
16#[derive(Clone, Debug)]
17pub struct ScaleDateTime {
18    aesthetic: Aesthetic,
19    name: String,
20    min: f64,
21    max: f64,
22    trained: bool,
23    expand: (f64, f64),
24    date_breaks: Option<DateBreak>,
25    date_labels: Option<String>,
26}
27
28/// Decomposed UTC date/time.
29struct DateParts {
30    year: i64,
31    month: u32,
32    day: u32,
33    hour: u32,
34    minute: u32,
35    second: u32,
36}
37
38/// Days since 1970-01-01 for a civil (Y, M, D) date — Howard Hinnant's algorithm.
39fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
40    let y = if m <= 2 { y - 1 } else { y };
41    let era = (if y >= 0 { y } else { y - 399 }) / 400;
42    let yoe = y - era * 400;
43    let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
44    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
45    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
46    era * 146_097 + doe - 719_468
47}
48
49fn secs_from_civil(y: i64, m: u32, d: u32) -> i64 {
50    days_from_civil(y, m, d) * 86_400
51}
52
53/// Inverse of `days_from_civil`, plus the intra-day time.
54fn civil_from_secs(secs: i64) -> DateParts {
55    let (mut days, rem) = if secs >= 0 {
56        (secs / 86_400, secs % 86_400)
57    } else {
58        let d = (secs - 86_400 + 1) / 86_400;
59        (d, secs - d * 86_400)
60    };
61    let hour = (rem / 3600) as u32;
62    let minute = ((rem % 3600) / 60) as u32;
63    let second = (rem % 60) as u32;
64
65    days += 719_468;
66    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
67    let doe = (days - era * 146_097) as u32;
68    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
69    let y = yoe as i64 + era * 400;
70    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
71    let mp = (5 * doy + 2) / 153;
72    let day = doy - (153 * mp + 2) / 5 + 1;
73    let month = if mp < 10 { mp + 3 } else { mp - 9 };
74    let year = if month <= 2 { y + 1 } else { y };
75    DateParts {
76        year,
77        month,
78        day,
79        hour,
80        minute,
81        second,
82    }
83}
84
85const MONTHS_SHORT: [&str; 12] = [
86    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
87];
88const MONTHS_LONG: [&str; 12] = [
89    "January",
90    "February",
91    "March",
92    "April",
93    "May",
94    "June",
95    "July",
96    "August",
97    "September",
98    "October",
99    "November",
100    "December",
101];
102
103/// Format a timestamp with a strftime-style subset:
104/// `%Y %y %m %b %B %d %e %H %M %S %%`.
105fn strftime(secs: f64, fmt: &str) -> String {
106    let p = civil_from_secs(secs as i64);
107    let mi = (p.month.clamp(1, 12) - 1) as usize;
108    let mut out = String::new();
109    let mut chars = fmt.chars();
110    while let Some(c) = chars.next() {
111        if c != '%' {
112            out.push(c);
113            continue;
114        }
115        match chars.next() {
116            Some('Y') => out.push_str(&format!("{:04}", p.year)),
117            Some('y') => out.push_str(&format!("{:02}", p.year.rem_euclid(100))),
118            Some('m') => out.push_str(&format!("{:02}", p.month)),
119            Some('b') => out.push_str(MONTHS_SHORT[mi]),
120            Some('B') => out.push_str(MONTHS_LONG[mi]),
121            Some('d') => out.push_str(&format!("{:02}", p.day)),
122            Some('e') => out.push_str(&format!("{:2}", p.day)),
123            Some('H') => out.push_str(&format!("{:02}", p.hour)),
124            Some('M') => out.push_str(&format!("{:02}", p.minute)),
125            Some('S') => out.push_str(&format!("{:02}", p.second)),
126            Some('%') => out.push('%'),
127            Some(other) => {
128                out.push('%');
129                out.push(other);
130            }
131            None => out.push('%'),
132        }
133    }
134    out
135}
136
137/// Parse an R-style break spec like "1 month", "3 months", "2 weeks", "1 year".
138fn parse_date_break(spec: &str) -> Option<DateBreak> {
139    let spec = spec.trim().to_lowercase();
140    let mut parts = spec.split_whitespace();
141    let first = parts.next()?;
142    let (n, unit) = match first.parse::<f64>() {
143        Ok(n) => (n, parts.next()?.to_string()),
144        Err(_) => (1.0, first.to_string()),
145    };
146    let unit = unit.trim_end_matches('s');
147    let secs = |s: f64| Some(DateBreak::Secs(n * s));
148    match unit {
149        "sec" | "second" => secs(1.0),
150        "min" | "minute" => secs(60.0),
151        "hour" => secs(3600.0),
152        "day" => secs(86_400.0),
153        "week" => secs(604_800.0),
154        "month" => Some(DateBreak::Months(n.max(1.0) as u32)),
155        "year" => Some(DateBreak::Months((n.max(1.0) as u32) * 12)),
156        _ => None,
157    }
158}
159
160impl ScaleDateTime {
161    pub fn new() -> Self {
162        ScaleDateTime {
163            aesthetic: Aesthetic::X,
164            name: String::new(),
165            min: f64::INFINITY,
166            max: f64::NEG_INFINITY,
167            trained: false,
168            expand: (0.05, 0.0),
169            date_breaks: None,
170            date_labels: None,
171        }
172    }
173
174    pub fn for_aesthetic(mut self, aes: Aesthetic) -> Self {
175        self.aesthetic = aes;
176        self
177    }
178
179    pub fn with_name(mut self, name: &str) -> Self {
180        self.name = name.to_string();
181        self
182    }
183
184    /// Calendar-aware break interval, R-style: `"1 month"`, `"3 months"`,
185    /// `"2 weeks"`, `"1 year"`, `"6 hours"`, … Unrecognised specs are ignored.
186    pub fn with_date_breaks(mut self, spec: &str) -> Self {
187        self.date_breaks = parse_date_break(spec);
188        self
189    }
190
191    /// strftime-style label format, e.g. `"%b %Y"` or `"%Y-%m-%d"`.
192    /// Supported: `%Y %y %m %b %B %d %e %H %M %S %%`.
193    pub fn with_date_labels(mut self, fmt: &str) -> Self {
194        self.date_labels = Some(fmt.to_string());
195        self
196    }
197
198    fn label(&self, secs: f64, step: f64) -> String {
199        match &self.date_labels {
200            Some(fmt) => strftime(secs, fmt),
201            None => Self::format_datetime(secs, step),
202        }
203    }
204
205    fn expanded_range(&self) -> (f64, f64) {
206        let range = self.max - self.min;
207        let mult = self.expand.0;
208        let add = self.expand.1;
209        (self.min - range * mult - add, self.max + range * mult + add)
210    }
211
212    /// Choose a "nice" step size in seconds for date/time breaks.
213    fn nice_datetime_step(range_secs: f64) -> f64 {
214        const MINUTE: f64 = 60.0;
215        const HOUR: f64 = 3600.0;
216        const DAY: f64 = 86400.0;
217        const WEEK: f64 = 7.0 * DAY;
218        const MONTH: f64 = 30.0 * DAY;
219        const YEAR: f64 = 365.25 * DAY;
220
221        let candidates = [
222            1.0,
223            5.0,
224            10.0,
225            30.0,
226            MINUTE,
227            5.0 * MINUTE,
228            10.0 * MINUTE,
229            30.0 * MINUTE,
230            HOUR,
231            3.0 * HOUR,
232            6.0 * HOUR,
233            12.0 * HOUR,
234            DAY,
235            2.0 * DAY,
236            WEEK,
237            2.0 * WEEK,
238            MONTH,
239            3.0 * MONTH,
240            6.0 * MONTH,
241            YEAR,
242            2.0 * YEAR,
243            5.0 * YEAR,
244            10.0 * YEAR,
245            20.0 * YEAR,
246            50.0 * YEAR,
247            100.0 * YEAR,
248        ];
249
250        let target = range_secs / 5.0;
251        for &c in &candidates {
252            if c >= target {
253                return c;
254            }
255        }
256        // For very large ranges, use multiples of 100 years
257        let n = (target / (100.0 * YEAR)).ceil();
258        n * 100.0 * YEAR
259    }
260
261    /// Format a timestamp (epoch seconds) as a human-readable label,
262    /// adapting precision to the break step size.
263    fn format_datetime(secs: f64, _step: f64) -> String {
264        let epoch_secs = secs as i64;
265        crate::data::format_epoch_secs(epoch_secs)
266    }
267}
268
269impl Default for ScaleDateTime {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275impl Scale for ScaleDateTime {
276    fn aesthetic(&self) -> Aesthetic {
277        self.aesthetic.clone()
278    }
279
280    fn train(&mut self, values: &[Value]) {
281        for v in values {
282            if let Some(f) = v.as_f64() {
283                if f.is_finite() {
284                    if f < self.min {
285                        self.min = f;
286                    }
287                    if f > self.max {
288                        self.max = f;
289                    }
290                }
291            }
292        }
293        self.trained = true;
294    }
295
296    fn map(&self, value: &Value) -> f64 {
297        let f = match value.as_f64() {
298            Some(f) => f,
299            None => return 0.0,
300        };
301        // ±Inf means "panel edge" (ggplot2): -Inf → lower edge, +Inf → upper.
302        if f == f64::INFINITY {
303            return 1.0;
304        }
305        if f == f64::NEG_INFINITY {
306            return 0.0;
307        }
308        // An untrained scale (empty data) has no domain — centre everything
309        // rather than producing NaN.
310        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
311            return 0.5;
312        }
313        let (emin, emax) = self.expanded_range();
314        let range = emax - emin;
315        if range.abs() < f64::EPSILON {
316            0.5
317        } else {
318            (f - emin) / range
319        }
320    }
321
322    fn expanded_domain(&self) -> Option<(f64, f64)> {
323        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
324            return None;
325        }
326        let (a, b) = self.expanded_range();
327        if !(a.is_finite() && b.is_finite()) {
328            return None;
329        }
330        if (b - a).abs() < f64::EPSILON {
331            // A degenerate domain maps everything to the panel centre.
332            return Some((a - 0.5, b + 0.5));
333        }
334        Some((a, b))
335    }
336
337    fn breaks(&self) -> Vec<(f64, String)> {
338        if !self.trained || self.min > self.max {
339            return vec![];
340        }
341
342        let range = self.max - self.min;
343        if super::util::is_degenerate_range(self.min, self.max) {
344            return vec![(0.5, self.label(self.min, 1.0))];
345        }
346
347        let (emin, emax) = self.expanded_range();
348        // Beyond ~±31,700 years the civil-calendar arithmetic below would
349        // overflow i64; such values are corrupt data, so draw no breaks.
350        const MAX_ABS_SECS: f64 = 1e12;
351        if !(emin.abs() <= MAX_ABS_SECS && emax.abs() <= MAX_ABS_SECS) {
352            return vec![];
353        }
354
355        // Calendar-month breaks snap to the first of the month.
356        if let Some(DateBreak::Months(n)) = self.date_breaks {
357            let n = n.max(1);
358            let start = civil_from_secs(emin.ceil() as i64);
359            let (mut y, mut m) = (start.year, start.month);
360            // First-of-month boundary at or after emin.
361            if (secs_from_civil(y, m, 1) as f64) < emin {
362                m += 1;
363                if m > 12 {
364                    m = 1;
365                    y += 1;
366                }
367            }
368            let mut breaks = Vec::new();
369            let mut guard = 0;
370            loop {
371                let secs = secs_from_civil(y, m, 1) as f64;
372                if secs > emax + 1.0 || guard > 10_000 {
373                    break;
374                }
375                breaks.push((self.map(&Value::Float(secs)), self.label(secs, 0.0)));
376                m += n;
377                while m > 12 {
378                    m -= 12;
379                    y += 1;
380                }
381                guard += 1;
382            }
383            return breaks;
384        }
385
386        let explicit_secs = matches!(self.date_breaks, Some(DateBreak::Secs(s)) if s > 0.0);
387        let step = match self.date_breaks {
388            Some(DateBreak::Secs(s)) if s > 0.0 => s,
389            _ => Self::nice_datetime_step(range),
390        };
391
392        // For month-or-larger auto steps, snap breaks to calendar boundaries with
393        // clean year / year-month labels (ggplot2-style) instead of fixed-second
394        // timestamps that drift off midnight (e.g. "2012-01-01 12:00:00").
395        const DAY: f64 = 86_400.0;
396        const MONTH: f64 = 30.0 * DAY;
397        const YEAR: f64 = 365.25 * DAY;
398        if !explicit_secs && step >= 0.9 * YEAR {
399            let n = ((step / YEAR).round() as i64).max(1);
400            let mut y = civil_from_secs(emin.ceil() as i64).year.div_euclid(n) * n;
401            while (secs_from_civil(y, 1, 1) as f64) < emin {
402                y += n;
403            }
404            let mut breaks = Vec::new();
405            while (secs_from_civil(y, 1, 1) as f64) <= emax + 1.0 {
406                let secs = secs_from_civil(y, 1, 1) as f64;
407                breaks.push((self.map(&Value::Float(secs)), format!("{y}")));
408                y += n;
409            }
410            return breaks;
411        }
412        if !explicit_secs && step >= 0.9 * MONTH {
413            let n = ((step / MONTH).round() as i64).max(1);
414            let start = civil_from_secs(emin.ceil() as i64);
415            let mut tm = start.year * 12 + (start.month as i64 - 1); // months since year 0
416            while (secs_from_civil(tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1, 1) as f64)
417                < emin
418            {
419                tm += 1;
420            }
421            let mut breaks = Vec::new();
422            let mut guard = 0;
423            loop {
424                let (y, m) = (tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1);
425                let secs = secs_from_civil(y, m, 1) as f64;
426                if secs > emax + 1.0 || guard > 10_000 {
427                    break;
428                }
429                breaks.push((self.map(&Value::Float(secs)), format!("{y:04}-{m:02}")));
430                tm += n;
431                guard += 1;
432            }
433            return breaks;
434        }
435
436        let start = (emin / step).ceil() * step;
437        super::util::stepped_breaks(start, emax, step)
438            .into_iter()
439            .map(|v| (self.map(&Value::Float(v)), self.label(v, step)))
440            .collect()
441    }
442
443    fn name(&self) -> &str {
444        &self.name
445    }
446
447    fn set_name(&mut self, name: &str) {
448        self.name = name.to_string();
449    }
450
451    fn set_limits(&mut self, min: f64, max: f64) {
452        self.min = min;
453        self.max = max;
454        self.trained = true;
455    }
456
457    fn clone_box(&self) -> Box<dyn Scale> {
458        Box::new(self.clone())
459    }
460
461    fn reset_training(&mut self) {
462        self.min = f64::INFINITY;
463        self.max = f64::NEG_INFINITY;
464        self.trained = false;
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn civil_roundtrip() {
474        // 2021-03-15 12:30:45 UTC = 1615811445
475        let p = civil_from_secs(1_615_811_445);
476        assert_eq!((p.year, p.month, p.day), (2021, 3, 15));
477        assert_eq!((p.hour, p.minute, p.second), (12, 30, 45));
478        assert_eq!(secs_from_civil(2021, 3, 15), 1_615_766_400); // midnight
479    }
480
481    #[test]
482    fn multiyear_default_breaks_are_clean_years() {
483        // A multi-year range with no explicit date_breaks should snap to calendar
484        // year boundaries and label with plain years (not drifting timestamps).
485        let mut s = ScaleDateTime::new();
486        s.train(&[
487            Value::DateTime(secs_from_civil(2011, 2, 1)),
488            Value::DateTime(secs_from_civil(2016, 4, 1)),
489        ]);
490        let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
491        assert!(!labels.is_empty());
492        for l in &labels {
493            assert!(
494                l.len() == 4 && l.chars().all(|c| c.is_ascii_digit()),
495                "expected a bare year label, got {l:?}"
496            );
497        }
498        assert!(labels.contains(&"2014".to_string()));
499    }
500
501    #[test]
502    fn strftime_subset() {
503        let s = 1_615_766_400.0; // 2021-03-15 00:00
504        assert_eq!(strftime(s, "%Y-%m-%d"), "2021-03-15");
505        assert_eq!(strftime(s, "%b %Y"), "Mar 2021");
506        assert_eq!(strftime(s, "%B"), "March");
507        assert_eq!(strftime(s, "100%%"), "100%");
508    }
509
510    #[test]
511    fn parse_specs() {
512        assert!(matches!(
513            parse_date_break("1 month"),
514            Some(DateBreak::Months(1))
515        ));
516        assert!(matches!(
517            parse_date_break("3 months"),
518            Some(DateBreak::Months(3))
519        ));
520        assert!(matches!(
521            parse_date_break("1 year"),
522            Some(DateBreak::Months(12))
523        ));
524        assert!(
525            matches!(parse_date_break("2 weeks"), Some(DateBreak::Secs(s)) if s == 1_209_600.0)
526        );
527        assert!(matches!(
528            parse_date_break("month"),
529            Some(DateBreak::Months(1))
530        ));
531        assert!(parse_date_break("fortnight").is_none());
532    }
533
534    #[test]
535    fn monthly_breaks_land_on_first_of_month() {
536        let mut s = ScaleDateTime::new()
537            .with_date_breaks("1 month")
538            .with_date_labels("%Y-%m-%d");
539        // Jan 10 2021 .. Apr 20 2021
540        s.set_limits(
541            secs_from_civil(2021, 1, 10) as f64,
542            secs_from_civil(2021, 4, 20) as f64,
543        );
544        let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
545        assert!(labels.iter().all(|l| l.ends_with("-01")), "{labels:?}");
546        assert!(labels.contains(&"2021-02-01".to_string()));
547    }
548}