1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::Scale;
5
6#[derive(Clone, Copy, Debug)]
8enum DateBreak {
9 Secs(f64),
11 Months(u32),
13}
14
15#[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: Option<crate::locale::Locale>,
28}
29
30struct DateParts {
32 year: i64,
33 month: u32,
34 day: u32,
35 hour: u32,
36 minute: u32,
37 second: u32,
38}
39
40fn 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
55fn 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#[cfg(test)]
118fn strftime(secs: f64, fmt: &str) -> String {
119 strftime_locale(secs, fmt, None)
120}
121
122pub(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 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 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
172fn 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 pub fn with_date_breaks(mut self, spec: &str) -> Self {
223 self.date_breaks = parse_date_break(spec);
224 self
225 }
226
227 pub fn with_date_labels(mut self, fmt: &str) -> Self {
231 self.date_labels = Some(fmt.to_string());
232 self
233 }
234
235 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 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 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 let n = (target / (100.0 * YEAR)).ceil();
314 n * 100.0 * YEAR
315 }
316
317 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 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 if f == f64::INFINITY {
383 return 1.0;
384 }
385 if f == f64::NEG_INFINITY {
386 return 0.0;
387 }
388 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 expanded_domain(&self) -> Option<(f64, f64)> {
403 if !self.trained || !self.min.is_finite() || !self.max.is_finite() {
404 return None;
405 }
406 let (a, b) = self.expanded_range();
407 if !(a.is_finite() && b.is_finite()) {
408 return None;
409 }
410 if (b - a).abs() < f64::EPSILON {
411 return Some((a - 0.5, b + 0.5));
413 }
414 Some((a, b))
415 }
416
417 fn breaks(&self) -> Vec<(f64, String)> {
418 if !self.trained || self.min > self.max {
419 return vec![];
420 }
421
422 let range = self.max - self.min;
423 if super::util::is_degenerate_range(self.min, self.max) {
424 return vec![(0.5, self.label(self.min, 1.0))];
425 }
426
427 let (emin, emax) = self.expanded_range();
428 const MAX_ABS_SECS: f64 = 1e12;
431 if !(emin.abs() <= MAX_ABS_SECS && emax.abs() <= MAX_ABS_SECS) {
432 return vec![];
433 }
434
435 if let Some(DateBreak::Months(n)) = self.date_breaks {
437 let n = n.max(1);
438 let start = civil_from_secs(emin.ceil() as i64);
439 let (mut y, mut m) = (start.year, start.month);
440 if (secs_from_civil(y, m, 1) as f64) < emin {
442 m += 1;
443 if m > 12 {
444 m = 1;
445 y += 1;
446 }
447 }
448 let mut breaks = Vec::new();
449 let mut guard = 0;
450 loop {
451 let secs = secs_from_civil(y, m, 1) as f64;
452 if secs > emax + 1.0 || guard > 10_000 {
453 break;
454 }
455 breaks.push((self.map(&Value::Float(secs)), self.label(secs, 0.0)));
456 m += n;
457 while m > 12 {
458 m -= 12;
459 y += 1;
460 }
461 guard += 1;
462 }
463 return breaks;
464 }
465
466 let explicit_secs = matches!(self.date_breaks, Some(DateBreak::Secs(s)) if s > 0.0);
467 let step = match self.date_breaks {
468 Some(DateBreak::Secs(s)) if s > 0.0 => s,
469 _ => Self::nice_datetime_step(range),
470 };
471
472 const DAY: f64 = 86_400.0;
476 const MONTH: f64 = 30.0 * DAY;
477 const YEAR: f64 = 365.25 * DAY;
478 if !explicit_secs && step >= 0.9 * YEAR {
479 let n = ((step / YEAR).round() as i64).max(1);
480 let mut y = civil_from_secs(emin.ceil() as i64).year.div_euclid(n) * n;
481 while (secs_from_civil(y, 1, 1) as f64) < emin {
482 y += n;
483 }
484 let mut breaks = Vec::new();
485 while (secs_from_civil(y, 1, 1) as f64) <= emax + 1.0 {
486 let secs = secs_from_civil(y, 1, 1) as f64;
487 breaks.push((self.map(&Value::Float(secs)), format!("{y}")));
488 y += n;
489 }
490 return breaks;
491 }
492 if !explicit_secs && step >= 0.9 * MONTH {
493 let n = ((step / MONTH).round() as i64).max(1);
494 let start = civil_from_secs(emin.ceil() as i64);
495 let mut tm = start.year * 12 + (start.month as i64 - 1); while (secs_from_civil(tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1, 1) as f64)
497 < emin
498 {
499 tm += 1;
500 }
501 let mut breaks = Vec::new();
502 let mut guard = 0;
503 loop {
504 let (y, m) = (tm.div_euclid(12), tm.rem_euclid(12) as u32 + 1);
505 let secs = secs_from_civil(y, m, 1) as f64;
506 if secs > emax + 1.0 || guard > 10_000 {
507 break;
508 }
509 breaks.push((self.map(&Value::Float(secs)), self.month_label(y, m, secs)));
510 tm += n;
511 guard += 1;
512 }
513 return breaks;
514 }
515
516 let start = (emin / step).ceil() * step;
517 super::util::stepped_breaks(start, emax, step)
518 .into_iter()
519 .map(|v| (self.map(&Value::Float(v)), self.label(v, step)))
520 .collect()
521 }
522
523 fn name(&self) -> &str {
524 &self.name
525 }
526
527 fn set_name(&mut self, name: &str) {
528 self.name = name.to_string();
529 }
530
531 fn set_limits(&mut self, min: f64, max: f64) {
532 self.min = min;
533 self.max = max;
534 self.trained = true;
535 }
536
537 fn clone_box(&self) -> Box<dyn Scale> {
538 Box::new(self.clone())
539 }
540
541 fn reset_training(&mut self) {
542 self.min = f64::INFINITY;
543 self.max = f64::NEG_INFINITY;
544 self.trained = false;
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn format_value_is_plain_iso_without_a_locale_and_locale_formatted_with_one() {
554 let s = ScaleDateTime::new();
555 assert_eq!(
557 s.format_value(&Value::Float(1_791_763_200.0)),
558 Some("2026-10-12".to_string())
559 );
560 assert_eq!(s.format_value(&Value::Float(f64::NAN)), None);
561
562 let mut with_locale = ScaleDateTime::new();
563 with_locale.set_default_locale(&crate::locale::Locale::de_de());
564 let formatted = with_locale
565 .format_value(&Value::Float(1_791_763_200.0))
566 .unwrap();
567 assert_ne!(formatted, "2026-10-12");
568 }
569
570 #[test]
571 fn format_value_honours_with_date_labels() {
572 let s = ScaleDateTime::new().with_date_labels("%d.%m.%Y");
576 assert_eq!(
577 s.format_value(&Value::Float(1_791_763_200.0)), Some("12.10.2026".to_string())
579 );
580 }
581
582 #[test]
583 fn civil_roundtrip() {
584 let p = civil_from_secs(1_615_811_445);
586 assert_eq!((p.year, p.month, p.day), (2021, 3, 15));
587 assert_eq!((p.hour, p.minute, p.second), (12, 30, 45));
588 assert_eq!(secs_from_civil(2021, 3, 15), 1_615_766_400); }
590
591 #[test]
592 fn multiyear_default_breaks_are_clean_years() {
593 let mut s = ScaleDateTime::new();
596 s.train(&[
597 Value::DateTime(secs_from_civil(2011, 2, 1)),
598 Value::DateTime(secs_from_civil(2016, 4, 1)),
599 ]);
600 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
601 assert!(!labels.is_empty());
602 for l in &labels {
603 assert!(
604 l.len() == 4 && l.chars().all(|c| c.is_ascii_digit()),
605 "expected a bare year label, got {l:?}"
606 );
607 }
608 assert!(labels.contains(&"2014".to_string()));
609 }
610
611 #[test]
612 fn strftime_subset() {
613 let s = 1_615_766_400.0; assert_eq!(strftime(s, "%Y-%m-%d"), "2021-03-15");
615 assert_eq!(strftime(s, "%b %Y"), "Mar 2021");
616 assert_eq!(strftime(s, "%B"), "March");
617 assert_eq!(strftime(s, "100%%"), "100%");
618 }
619
620 #[test]
621 fn strftime_weekdays_and_unpadded() {
622 let s = 1_615_766_400.0; assert_eq!(strftime(s, "%a %A"), "Mon Monday");
624 assert_eq!(strftime(s - 86_400.0 * 10.0, "%-d.%-m. %d"), "5.3. 05");
625 assert_eq!(strftime(s, "%-x"), "%-x");
626 assert_eq!(strftime(-86_400.0, "%a"), "Wed"); }
628
629 #[test]
630 fn locale_month_and_day_labels() {
631 let de = crate::locale::Locale::de_de();
632 let mut s = ScaleDateTime::new().with_locale(de.clone());
634 s.set_limits(
635 secs_from_civil(2026, 7, 1) as f64,
636 secs_from_civil(2027, 2, 1) as f64,
637 );
638 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
639 assert!(labels.contains(&"Okt. 2026".to_string()), "{labels:?}");
640 assert!(labels.contains(&"Jan. 2027".to_string()), "{labels:?}");
641 let mut plain = ScaleDateTime::new();
643 plain.set_limits(
644 secs_from_civil(2026, 7, 1) as f64,
645 secs_from_civil(2027, 2, 1) as f64,
646 );
647 let labels: Vec<String> = plain.breaks().into_iter().map(|(_, l)| l).collect();
648 assert!(labels.contains(&"2026-10".to_string()), "{labels:?}");
649 let mut f = ScaleDateTime::new()
651 .with_date_labels("%B")
652 .with_date_breaks("1 month");
653 f.set_default_locale(&de);
654 f.set_limits(
655 secs_from_civil(2026, 2, 20) as f64,
656 secs_from_civil(2026, 4, 2) as f64,
657 );
658 let labels: Vec<String> = f.breaks().into_iter().map(|(_, l)| l).collect();
659 assert_eq!(labels, ["März", "April"]);
660 let mut d = ScaleDateTime::new().with_locale(de);
662 d.set_limits(
663 secs_from_civil(2026, 10, 12) as f64,
664 secs_from_civil(2026, 10, 16) as f64,
665 );
666 let labels: Vec<String> = d.breaks().into_iter().map(|(_, l)| l).collect();
667 assert!(labels.contains(&"15. Okt. 2026".to_string()), "{labels:?}");
668 let mut own = ScaleDateTime::new().with_locale(crate::locale::Locale::fr_fr());
670 own.set_default_locale(&crate::locale::Locale::de_de());
671 assert_eq!(own.locale().unwrap().tag, "fr-FR");
672 }
673
674 #[test]
675 fn parse_specs() {
676 assert!(matches!(
677 parse_date_break("1 month"),
678 Some(DateBreak::Months(1))
679 ));
680 assert!(matches!(
681 parse_date_break("3 months"),
682 Some(DateBreak::Months(3))
683 ));
684 assert!(matches!(
685 parse_date_break("1 year"),
686 Some(DateBreak::Months(12))
687 ));
688 assert!(
689 matches!(parse_date_break("2 weeks"), Some(DateBreak::Secs(s)) if s == 1_209_600.0)
690 );
691 assert!(matches!(
692 parse_date_break("month"),
693 Some(DateBreak::Months(1))
694 ));
695 assert!(parse_date_break("fortnight").is_none());
696 }
697
698 #[test]
699 fn monthly_breaks_land_on_first_of_month() {
700 let mut s = ScaleDateTime::new()
701 .with_date_breaks("1 month")
702 .with_date_labels("%Y-%m-%d");
703 s.set_limits(
705 secs_from_civil(2021, 1, 10) as f64,
706 secs_from_civil(2021, 4, 20) as f64,
707 );
708 let labels: Vec<String> = s.breaks().into_iter().map(|(_, l)| l).collect();
709 assert!(labels.iter().all(|l| l.ends_with("-01")), "{labels:?}");
710 assert!(labels.contains(&"2021-02-01".to_string()));
711 }
712}