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    /// About this many breaks (an automatic step), else about five.
27    n_breaks: Option<usize>,
28    /// Locale for month/weekday names and default date formats.
29    locale: Option<crate::locale::Locale>,
30}
31
32/// Decomposed UTC date/time.
33struct DateParts {
34    year: i64,
35    month: u32,
36    day: u32,
37    hour: u32,
38    minute: u32,
39    second: u32,
40}
41
42/// Days since 1970-01-01 for a civil (Y, M, D) date — Howard Hinnant's algorithm.
43fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
44    let y = if m <= 2 { y - 1 } else { y };
45    let era = (if y >= 0 { y } else { y - 399 }) / 400;
46    let yoe = y - era * 400;
47    let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
48    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
49    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
50    era * 146_097 + doe - 719_468
51}
52
53fn secs_from_civil(y: i64, m: u32, d: u32) -> i64 {
54    days_from_civil(y, m, d) * 86_400
55}
56
57/// Inverse of `days_from_civil`, plus the intra-day time.
58fn civil_from_secs(secs: i64) -> DateParts {
59    let (mut days, rem) = if secs >= 0 {
60        (secs / 86_400, secs % 86_400)
61    } else {
62        let d = (secs - 86_400 + 1) / 86_400;
63        (d, secs - d * 86_400)
64    };
65    let hour = (rem / 3600) as u32;
66    let minute = ((rem % 3600) / 60) as u32;
67    let second = (rem % 60) as u32;
68
69    days += 719_468;
70    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
71    let doe = (days - era * 146_097) as u32;
72    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
73    let y = yoe as i64 + era * 400;
74    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
75    let mp = (5 * doy + 2) / 153;
76    let day = doy - (153 * mp + 2) / 5 + 1;
77    let month = if mp < 10 { mp + 3 } else { mp - 9 };
78    let year = if month <= 2 { y + 1 } else { y };
79    DateParts {
80        year,
81        month,
82        day,
83        hour,
84        minute,
85        second,
86    }
87}
88
89const MONTHS_SHORT: [&str; 12] = [
90    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
91];
92const MONTHS_LONG: [&str; 12] = [
93    "January",
94    "February",
95    "March",
96    "April",
97    "May",
98    "June",
99    "July",
100    "August",
101    "September",
102    "October",
103    "November",
104    "December",
105];
106const DAYS_SHORT: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
107const DAYS_LONG: [&str; 7] = [
108    "Sunday",
109    "Monday",
110    "Tuesday",
111    "Wednesday",
112    "Thursday",
113    "Friday",
114    "Saturday",
115];
116
117/// Format a timestamp with a strftime-style subset:
118/// `%Y %y %m %-m %b %B %d %-d %e %a %A %H %M %S %%`.
119#[cfg(test)]
120fn strftime(secs: f64, fmt: &str) -> String {
121    strftime_locale(secs, fmt, None)
122}
123
124/// [`strftime`] with the month / weekday names of `locale` (English without).
125pub(crate) fn strftime_locale(
126    secs: f64,
127    fmt: &str,
128    locale: Option<&crate::locale::Locale>,
129) -> String {
130    let p = civil_from_secs(secs as i64);
131    let mi = (p.month.clamp(1, 12) - 1) as usize;
132    // 1970-01-01 was a Thursday (index 4, Sunday = 0).
133    let wd = ((secs as i64).div_euclid(86_400) + 4).rem_euclid(7) as usize;
134    let month_short = locale.map_or(MONTHS_SHORT[mi], |l| l.months_short[mi].as_str());
135    let month_long = locale.map_or(MONTHS_LONG[mi], |l| l.months_long[mi].as_str());
136    let day_short = locale.map_or(DAYS_SHORT[wd], |l| l.days_short[wd].as_str());
137    let day_long = locale.map_or(DAYS_LONG[wd], |l| l.days_long[wd].as_str());
138    let mut out = String::new();
139    let mut chars = fmt.chars().peekable();
140    while let Some(c) = chars.next() {
141        if c != '%' {
142            out.push(c);
143            continue;
144        }
145        match chars.next() {
146            Some('Y') => out.push_str(&format!("{:04}", p.year)),
147            Some('y') => out.push_str(&format!("{:02}", p.year.rem_euclid(100))),
148            Some('m') => out.push_str(&format!("{:02}", p.month)),
149            Some('b') => out.push_str(month_short),
150            Some('B') => out.push_str(month_long),
151            Some('a') => out.push_str(day_short),
152            Some('A') => out.push_str(day_long),
153            Some('d') => out.push_str(&format!("{:02}", p.day)),
154            Some('e') => out.push_str(&format!("{:2}", p.day)),
155            Some('H') => out.push_str(&format!("{:02}", p.hour)),
156            Some('M') => out.push_str(&format!("{:02}", p.minute)),
157            Some('S') => out.push_str(&format!("{:02}", p.second)),
158            Some('%') => out.push('%'),
159            // glibc's "no padding" flag: %-d, %-m.
160            Some('-') if matches!(chars.peek(), Some('d' | 'm')) => match chars.next() {
161                Some('d') => out.push_str(&p.day.to_string()),
162                _ => out.push_str(&p.month.to_string()),
163            },
164            Some(other) => {
165                out.push('%');
166                out.push(other);
167            }
168            None => out.push('%'),
169        }
170    }
171    out
172}
173
174/// Parse an R-style break spec like "1 month", "3 months", "2 weeks", "1 year".
175fn parse_date_break(spec: &str) -> Option<DateBreak> {
176    let spec = spec.trim().to_lowercase();
177    let mut parts = spec.split_whitespace();
178    let first = parts.next()?;
179    let (n, unit) = match first.parse::<f64>() {
180        Ok(n) => (n, parts.next()?.to_string()),
181        Err(_) => (1.0, first.to_string()),
182    };
183    let unit = unit.trim_end_matches('s');
184    let secs = |s: f64| Some(DateBreak::Secs(n * s));
185    match unit {
186        "sec" | "second" => secs(1.0),
187        "min" | "minute" => secs(60.0),
188        "hour" => secs(3600.0),
189        "day" => secs(86_400.0),
190        "week" => secs(604_800.0),
191        "month" => Some(DateBreak::Months(n.max(1.0) as u32)),
192        "year" => Some(DateBreak::Months((n.max(1.0) as u32) * 12)),
193        _ => None,
194    }
195}
196
197impl ScaleDateTime {
198    pub fn new() -> Self {
199        ScaleDateTime {
200            aesthetic: Aesthetic::X,
201            name: String::new(),
202            min: f64::INFINITY,
203            max: f64::NEG_INFINITY,
204            trained: false,
205            expand: (0.05, 0.0),
206            date_breaks: None,
207            date_labels: None,
208            n_breaks: None,
209            locale: None,
210        }
211    }
212
213    pub fn for_aesthetic(mut self, aes: Aesthetic) -> Self {
214        self.aesthetic = aes;
215        self
216    }
217
218    pub fn with_name(mut self, name: &str) -> Self {
219        self.name = name.to_string();
220        self
221    }
222
223    /// Calendar-aware break interval, R-style: `"1 month"`, `"3 months"`,
224    /// `"2 weeks"`, `"1 year"`, `"6 hours"`, … Unrecognised specs are ignored.
225    pub fn with_date_breaks(mut self, spec: &str) -> Self {
226        self.date_breaks = parse_date_break(spec);
227        self
228    }
229
230    /// About `n` breaks (`n.breaks`): the automatic step is chosen for that
231    /// many instead of about five. An explicit `date_breaks` wins.
232    pub fn with_n_breaks(mut self, n: usize) -> Self {
233        self.n_breaks = Some(n.max(1));
234        self
235    }
236
237    /// strftime-style label format, e.g. `"%b %Y"` or `"%Y-%m-%d"`.
238    /// Supported: `%Y %y %m %-m %b %B %d %-d %e %a %A %H %M %S %%`; month and
239    /// weekday names follow the scale's locale (English without one).
240    pub fn with_date_labels(mut self, fmt: &str) -> Self {
241        self.date_labels = Some(fmt.to_string());
242        self
243    }
244
245    /// Localise month / weekday names and the default date labels: with
246    /// de-DE, monthly breaks read `Okt. 2026` (instead of `2026-10`) and daily
247    /// breaks `15. Okt. 2026` (instead of `2026-10-15`). Overrides the plot
248    /// locale ([`GGPlot::locale`](crate::GGPlot::locale)).
249    pub fn with_locale(mut self, locale: crate::locale::Locale) -> Self {
250        self.locale = Some(locale);
251        self
252    }
253
254    fn label(&self, secs: f64, step: f64) -> String {
255        match (&self.date_labels, &self.locale) {
256            (Some(fmt), loc) => strftime_locale(secs, fmt, loc.as_ref()),
257            (None, Some(loc)) => loc.format_datetime(secs as i64),
258            (None, None) => Self::format_datetime(secs, step),
259        }
260    }
261
262    /// Default label of an auto month-step break (`2026-10`, or the locale's
263    /// [`month_format`](crate::locale::Locale::month_format)).
264    fn month_label(&self, y: i64, m: u32, secs: f64) -> String {
265        if let Some(fmt) = &self.date_labels {
266            return strftime_locale(secs, fmt, self.locale.as_ref());
267        }
268        match &self.locale {
269            Some(loc) => strftime_locale(secs, &loc.month_format, Some(loc)),
270            None => format!("{y:04}-{m:02}"),
271        }
272    }
273
274    /// Label of an auto year-step break: `with_date_labels` when set, else `2026`.
275    fn year_label(&self, y: i64, secs: f64) -> String {
276        match &self.date_labels {
277            Some(fmt) => strftime_locale(secs, fmt, self.locale.as_ref()),
278            None => format!("{y}"),
279        }
280    }
281
282    fn expanded_range(&self) -> (f64, f64) {
283        let range = self.max - self.min;
284        let mult = self.expand.0;
285        let add = self.expand.1;
286        (self.min - range * mult - add, self.max + range * mult + add)
287    }
288
289    /// Choose a "nice" step size in seconds for date/time breaks.
290    fn nice_datetime_step(range_secs: f64, n: usize) -> f64 {
291        const MINUTE: f64 = 60.0;
292        const HOUR: f64 = 3600.0;
293        const DAY: f64 = 86400.0;
294        const WEEK: f64 = 7.0 * DAY;
295        const MONTH: f64 = 30.0 * DAY;
296        const YEAR: f64 = 365.25 * DAY;
297
298        let candidates = [
299            1.0,
300            5.0,
301            10.0,
302            30.0,
303            MINUTE,
304            5.0 * MINUTE,
305            10.0 * MINUTE,
306            30.0 * MINUTE,
307            HOUR,
308            3.0 * HOUR,
309            6.0 * HOUR,
310            12.0 * HOUR,
311            DAY,
312            2.0 * DAY,
313            WEEK,
314            2.0 * WEEK,
315            MONTH,
316            3.0 * MONTH,
317            6.0 * MONTH,
318            YEAR,
319            2.0 * YEAR,
320            5.0 * YEAR,
321            10.0 * YEAR,
322            20.0 * YEAR,
323            50.0 * YEAR,
324            100.0 * YEAR,
325        ];
326
327        let target = range_secs / n.max(1) as f64;
328        for &c in &candidates {
329            if c >= target {
330                return c;
331            }
332        }
333        // For very large ranges, use multiples of 100 years
334        let n = (target / (100.0 * YEAR)).ceil();
335        n * 100.0 * YEAR
336    }
337
338    /// Format a timestamp (epoch seconds) as a human-readable label,
339    /// adapting precision to the break step size.
340    fn format_datetime(secs: f64, _step: f64) -> String {
341        let epoch_secs = secs as i64;
342        crate::data::format_epoch_secs(epoch_secs)
343    }
344}
345
346impl Default for ScaleDateTime {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352impl Scale for ScaleDateTime {
353    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
354        if self.locale.is_none() {
355            self.locale = Some(locale.clone());
356        }
357    }
358
359    fn locale(&self) -> Option<&crate::locale::Locale> {
360        self.locale.as_ref()
361    }
362
363    fn format_value(&self, value: &Value) -> Option<String> {
364        let f = value.as_f64()?;
365        if !f.is_finite() {
366            return None;
367        }
368        // Review PR#141 finding 5: honour `with_date_labels` (and locale)
369        // the same way the axis break labels already do, via the shared
370        // `label` helper — not just the bare ISO/locale-date fallback.
371        // `label`'s `step` parameter only affects the no-format/no-locale
372        // branch's precision choice, which is irrelevant for a single
373        // point value, so 0.0 is fine.
374        Some(self.label(f, 0.0))
375    }
376
377    fn aesthetic(&self) -> Aesthetic {
378        self.aesthetic.clone()
379    }
380
381    fn train(&mut self, values: &[Value]) {
382        for v in values {
383            if let Some(f) = v.as_f64() {
384                if f.is_finite() {
385                    if f < self.min {
386                        self.min = f;
387                    }
388                    if f > self.max {
389                        self.max = f;
390                    }
391                }
392            }
393        }
394        self.trained = true;
395    }
396
397    fn map(&self, value: &Value) -> f64 {
398        let f = match value.as_f64() {
399            Some(f) => f,
400            None => return 0.0,
401        };
402        // ±Inf means "panel edge" (ggplot2): -Inf → lower edge, +Inf → upper.
403        if f == f64::INFINITY {
404            return 1.0;
405        }
406        if f == f64::NEG_INFINITY {
407            return 0.0;
408        }
409        // An untrained scale (empty data) has no domain — centre everything
410        // rather than producing NaN.
411        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
412            return 0.5;
413        }
414        let (emin, emax) = self.expanded_range();
415        let range = emax - emin;
416        if range.abs() < f64::EPSILON {
417            0.5
418        } else {
419            (f - emin) / range
420        }
421    }
422
423    fn domain(&self) -> Option<(f64, f64)> {
424        if self.trained && self.min.is_finite() && self.max.is_finite() {
425            Some((self.min, self.max))
426        } else {
427            None
428        }
429    }
430
431    fn expanded_domain(&self) -> Option<(f64, f64)> {
432        if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
433            return None;
434        }
435        let (a, b) = self.expanded_range();
436        if !(a.is_finite() && b.is_finite()) {
437            return None;
438        }
439        if (b - a).abs() < f64::EPSILON {
440            // A degenerate domain maps everything to the panel centre.
441            return Some((a - 0.5, b + 0.5));
442        }
443        Some((a, b))
444    }
445
446    fn breaks(&self) -> Vec<(f64, String)> {
447        if !self.trained || self.min > self.max {
448            return vec![];
449        }
450
451        let range = self.max - self.min;
452        if super::util::is_degenerate_range(self.min, self.max) {
453            return vec![(0.5, self.label(self.min, 1.0))];
454        }
455
456        let (emin, emax) = self.expanded_range();
457        // Beyond ~±31,700 years the civil-calendar arithmetic below would
458        // overflow i64; such values are corrupt data, so draw no breaks.
459        const MAX_ABS_SECS: f64 = 1e12;
460        if !(emin.abs() <= MAX_ABS_SECS && emax.abs() <= MAX_ABS_SECS) {
461            return vec![];
462        }
463
464        // Calendar-month breaks snap to the first of the month.
465        if let Some(DateBreak::Months(n)) = self.date_breaks {
466            let n = n.max(1);
467            let start = civil_from_secs(emin.ceil() as i64);
468            let (mut y, mut m) = (start.year, start.month);
469            // First-of-month boundary at or after emin.
470            if (secs_from_civil(y, m, 1) as f64) < emin {
471                m += 1;
472                if m > 12 {
473                    m = 1;
474                    y += 1;
475                }
476            }
477            let mut breaks = Vec::new();
478            let mut guard = 0;
479            loop {
480                let secs = secs_from_civil(y, m, 1) as f64;
481                if secs > emax + 1.0 || guard > 10_000 {
482                    break;
483                }
484                breaks.push((self.map(&Value::Float(secs)), self.label(secs, 0.0)));
485                m += n;
486                while m > 12 {
487                    m -= 12;
488                    y += 1;
489                }
490                guard += 1;
491            }
492            return breaks;
493        }
494
495        let explicit_secs = matches!(self.date_breaks, Some(DateBreak::Secs(s)) if s > 0.0);
496        let step = match self.date_breaks {
497            Some(DateBreak::Secs(s)) if s > 0.0 => s,
498            _ => Self::nice_datetime_step(range, self.n_breaks.unwrap_or(5)),
499        };
500
501        // For month-or-larger auto steps, snap breaks to calendar boundaries with
502        // clean year / year-month labels (ggplot2-style) instead of fixed-second
503        // timestamps that drift off midnight (e.g. "2012-01-01 12:00:00").
504        const DAY: f64 = 86_400.0;
505        const MONTH: f64 = 30.0 * DAY;
506        const YEAR: f64 = 365.25 * DAY;
507        if !explicit_secs && step >= 0.9 * YEAR {
508            let n = ((step / YEAR).round() as i64).max(1);
509            let mut y = civil_from_secs(emin.ceil() as i64).year.div_euclid(n) * n;
510            while (secs_from_civil(y, 1, 1) as f64) < emin {
511                y += n;
512            }
513            let mut breaks = Vec::new();
514            while (secs_from_civil(y, 1, 1) as f64) <= emax + 1.0 {
515                let secs = secs_from_civil(y, 1, 1) as f64;
516                breaks.push((self.map(&Value::Float(secs)), self.year_label(y, secs)));
517                y += n;
518            }
519            return breaks;
520        }
521        if !explicit_secs && step >= 0.9 * MONTH {
522            let n = ((step / MONTH).round() as i64).max(1);
523            let start = civil_from_secs(emin.ceil() as i64);
524            let mut tm = start.year * 12 + (start.month as i64 - 1); // months since year 0
525            while (secs_from_civil(tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1, 1) as f64)
526                < emin
527            {
528                tm += 1;
529            }
530            let mut breaks = Vec::new();
531            let mut guard = 0;
532            loop {
533                let (y, m) = (tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1);
534                let secs = secs_from_civil(y, m, 1) as f64;
535                if secs > emax + 1.0 || guard > 10_000 {
536                    break;
537                }
538                breaks.push((self.map(&Value::Float(secs)), self.month_label(y, m, secs)));
539                tm += n;
540                guard += 1;
541            }
542            return breaks;
543        }
544
545        let start = (emin / step).ceil() * step;
546        super::util::stepped_breaks(start, emax, step)
547            .into_iter()
548            .map(|v| (self.map(&Value::Float(v)), self.label(v, step)))
549            .collect()
550    }
551
552    fn name(&self) -> &str {
553        &self.name
554    }
555
556    fn set_name(&mut self, name: &str) {
557        self.name = name.to_string();
558    }
559
560    fn set_limits(&mut self, min: f64, max: f64) {
561        self.min = min;
562        self.max = max;
563        self.trained = true;
564    }
565
566    fn clone_box(&self) -> Box<dyn Scale> {
567        Box::new(self.clone())
568    }
569
570    fn reset_training(&mut self) {
571        self.min = f64::INFINITY;
572        self.max = f64::NEG_INFINITY;
573        self.trained = false;
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn format_value_is_plain_iso_without_a_locale_and_locale_formatted_with_one() {
583        let s = ScaleDateTime::new();
584        // 2026-10-12T00:00:00Z.
585        assert_eq!(
586            s.format_value(&Value::Float(1_791_763_200.0)),
587            Some("2026-10-12".to_string())
588        );
589        assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
590
591        let mut with_locale = ScaleDateTime::new();
592        with_locale.set_default_locale(&crate::locale::Locale::de_de());
593        let formatted = with_locale
594            .format_value(&Value::Float(1_791_763_200.0))
595            .unwrap();
596        assert_ne!(formatted, "2026-10-12");
597    }
598
599    #[test]
600    fn format_value_honours_with_date_labels() {
601        // Review PR#141 finding 5: format_value ignored with_date_labels
602        // entirely, always falling back to ISO/locale — the axis break
603        // labels (via the private `label` helper) already honoured it.
604        let s = ScaleDateTime::new().with_date_labels("%d.%m.%Y");
605        assert_eq!(
606            s.format_value(&Value::Float(1_791_763_200.0)), // 2026-10-12
607            Some("12.10.2026".to_string())
608        );
609    }
610
611    #[test]
612    fn auto_month_and_year_breaks_honour_with_date_labels() {
613        // #352: the calendar-aware month/year branches labelled breaks
614        // themselves and ignored `with_date_labels`.
615        let monthly = |fmt: Option<&str>| {
616            let mut s = ScaleDateTime::new();
617            if let Some(f) = fmt {
618                s = s.with_date_labels(f);
619            }
620            // 18 monthly dates, 2025-01 … 2026-06.
621            let vals: Vec<Value> = (0..18)
622                .map(
623                    |i| Value::Float(secs_from_civil(2025 + i / 12, (i % 12) as u32 + 1, 1) as f64),
624                )
625                .collect();
626            s.train(&vals);
627            s.breaks().into_iter().map(|(_, l)| l).collect::<Vec<_>>()
628        };
629        assert_eq!(monthly(None), vec!["2025-01", "2025-07", "2026-01"]);
630        assert_eq!(
631            monthly(Some("%b %Y")),
632            vec!["Jan 2025", "Jul 2025", "Jan 2026"]
633        );
634        assert_eq!(monthly(Some("%b")), vec!["Jan", "Jul", "Jan"]);
635
636        let yearly = |fmt: Option<&str>| {
637            let mut s = ScaleDateTime::new();
638            if let Some(f) = fmt {
639                s = s.with_date_labels(f);
640            }
641            let vals: Vec<Value> = (2010..2021)
642                .map(|y| Value::Float(secs_from_civil(y, 1, 1) as f64))
643                .collect();
644            s.train(&vals);
645            s.breaks().into_iter().map(|(_, l)| l).collect::<Vec<_>>()
646        };
647        let plain = yearly(None);
648        assert!(
649            !plain.is_empty() && plain.iter().all(|l| l.len() == 4),
650            "{plain:?}"
651        );
652        assert_eq!(yearly(Some("%Y")), plain);
653        assert_eq!(
654            yearly(Some("%b %Y")),
655            plain.iter().map(|y| format!("Jan {y}")).collect::<Vec<_>>()
656        );
657    }
658
659    #[test]
660    fn civil_roundtrip() {
661        // 2021-03-15 12:30:45 UTC = 1615811445
662        let p = civil_from_secs(1_615_811_445);
663        assert_eq!((p.year, p.month, p.day), (2021, 3, 15));
664        assert_eq!((p.hour, p.minute, p.second), (12, 30, 45));
665        assert_eq!(secs_from_civil(2021, 3, 15), 1_615_766_400); // midnight
666    }
667
668    #[test]
669    fn multiyear_default_breaks_are_clean_years() {
670        // A multi-year range with no explicit date_breaks should snap to calendar
671        // year boundaries and label with plain years (not drifting timestamps).
672        let mut s = ScaleDateTime::new();
673        s.train(&[
674            Value::DateTime(secs_from_civil(2011, 2, 1)),
675            Value::DateTime(secs_from_civil(2016, 4, 1)),
676        ]);
677        let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
678        assert!(!labels.is_empty());
679        for l in &labels {
680            assert!(
681                l.len() == 4 && l.chars().all(|c| c.is_ascii_digit()),
682                "expected a bare year label, got {l:?}"
683            );
684        }
685        assert!(labels.contains(&"2014".to_string()));
686    }
687
688    #[test]
689    fn strftime_subset() {
690        let s = 1_615_766_400.0; // 2021-03-15 00:00
691        assert_eq!(strftime(s, "%Y-%m-%d"), "2021-03-15");
692        assert_eq!(strftime(s, "%b %Y"), "Mar 2021");
693        assert_eq!(strftime(s, "%B"), "March");
694        assert_eq!(strftime(s, "100%%"), "100%");
695    }
696
697    #[test]
698    fn strftime_weekdays_and_unpadded() {
699        let s = 1_615_766_400.0; // 2021-03-15 00:00, a Monday
700        assert_eq!(strftime(s, "%a %A"), "Mon Monday");
701        assert_eq!(strftime(s - 86_400.0 * 10.0, "%-d.%-m. %d"), "5.3. 05");
702        assert_eq!(strftime(s, "%-x"), "%-x");
703        assert_eq!(strftime(-86_400.0, "%a"), "Wed"); // 1969-12-31
704    }
705
706    #[test]
707    fn locale_month_and_day_labels() {
708        let de = crate::locale::Locale::de_de();
709        // Auto monthly breaks: "Okt. 2026" instead of "2026-10".
710        let mut s = ScaleDateTime::new().with_locale(de.clone());
711        s.set_limits(
712            secs_from_civil(2026, 7, 1) as f64,
713            secs_from_civil(2027, 2, 1) as f64,
714        );
715        let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
716        assert!(labels.contains(&"Okt. 2026".to_string()), "{labels:?}");
717        assert!(labels.contains(&"Jan. 2027".to_string()), "{labels:?}");
718        // Same scale without locale: unchanged ISO labels.
719        let mut plain = ScaleDateTime::new();
720        plain.set_limits(
721            secs_from_civil(2026, 7, 1) as f64,
722            secs_from_civil(2027, 2, 1) as f64,
723        );
724        let labels: Vec<String> = plain.breaks().into_iter().map(|(_, l)| l).collect();
725        assert!(labels.contains(&"2026-10".to_string()), "{labels:?}");
726        // Explicit format: localized names.
727        let mut f = ScaleDateTime::new()
728            .with_date_labels("%B")
729            .with_date_breaks("1 month");
730        f.set_default_locale(&de);
731        f.set_limits(
732            secs_from_civil(2026, 2, 20) as f64,
733            secs_from_civil(2026, 4, 2) as f64,
734        );
735        let labels: Vec<String> = f.breaks().into_iter().map(|(_, l)| l).collect();
736        assert_eq!(labels, ["März", "April"]);
737        // Day-level default labels.
738        let mut d = ScaleDateTime::new().with_locale(de);
739        d.set_limits(
740            secs_from_civil(2026, 10, 12) as f64,
741            secs_from_civil(2026, 10, 16) as f64,
742        );
743        let labels: Vec<String> = d.breaks().into_iter().map(|(_, l)| l).collect();
744        assert!(labels.contains(&"15. Okt. 2026".to_string()), "{labels:?}");
745        // A scale's own locale beats the plot default.
746        let mut own = ScaleDateTime::new().with_locale(crate::locale::Locale::fr_fr());
747        own.set_default_locale(&crate::locale::Locale::de_de());
748        assert_eq!(own.locale().unwrap().tag, "fr-FR");
749    }
750
751    #[test]
752    fn parse_specs() {
753        assert!(matches!(
754            parse_date_break("1 month"),
755            Some(DateBreak::Months(1))
756        ));
757        assert!(matches!(
758            parse_date_break("3 months"),
759            Some(DateBreak::Months(3))
760        ));
761        assert!(matches!(
762            parse_date_break("1 year"),
763            Some(DateBreak::Months(12))
764        ));
765        assert!(
766            matches!(parse_date_break("2 weeks"), Some(DateBreak::Secs(s)) if s == 1_209_600.0)
767        );
768        assert!(matches!(
769            parse_date_break("month"),
770            Some(DateBreak::Months(1))
771        ));
772        assert!(parse_date_break("fortnight").is_none());
773    }
774
775    #[test]
776    fn monthly_breaks_land_on_first_of_month() {
777        let mut s = ScaleDateTime::new()
778            .with_date_breaks("1 month")
779            .with_date_labels("%Y-%m-%d");
780        // Jan 10 2021 .. Apr 20 2021
781        s.set_limits(
782            secs_from_civil(2021, 1, 10) as f64,
783            secs_from_civil(2021, 4, 20) as f64,
784        );
785        let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
786        assert!(labels.iter().all(|l| l.ends_with("-01")), "{labels:?}");
787        assert!(labels.contains(&"2021-02-01".to_string()));
788    }
789}