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