1const MICROSECONDS_PER_SECOND: f64 = 1_000_000.0;
35const SECONDS_PER_MINUTE: f64 = 60.0;
36const SECONDS_PER_HOUR: f64 = 60.0 * SECONDS_PER_MINUTE;
37const SECONDS_PER_DAY: f64 = 24.0 * SECONDS_PER_HOUR;
38const SECONDS_PER_YEAR: f64 = 365.25 * SECONDS_PER_DAY;
39const SECONDS_PER_MONTH_AVERAGE: f64 = SECONDS_PER_YEAR / 12.0;
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
45pub enum DtUnit {
46 Years = 0,
48 Months = 1,
50 Days = 2,
52 Hours = 3,
54 Minutes = 4,
56 Seconds = 5,
58 MicroSeconds = 6,
60}
61
62impl DtUnit {
63 fn nice_values(self) -> &'static [f64] {
67 match self {
68 DtUnit::Years => &[1.0, 2.0, 5.0, 10.0],
69 DtUnit::Months => &[1.0, 2.0, 3.0, 4.0, 6.0, 12.0],
70 DtUnit::Days => &[1.0, 2.0, 3.0, 7.0, 14.0, 28.0],
71 DtUnit::Hours => &[1.0, 2.0, 3.0, 4.0, 6.0, 12.0],
72 DtUnit::Minutes => &[1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0],
73 DtUnit::Seconds => &[1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0],
74 DtUnit::MicroSeconds => &[1.0, 2.0, 3.0, 4.0, 5.0, 10.0],
75 }
76 }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum TimeZone {
96 Utc,
98 FixedOffset {
102 seconds_east: i32,
104 },
105 Named(tz::TimeZoneRef<'static>),
110}
111
112impl TimeZone {
113 pub fn named(name: &str) -> Option<TimeZone> {
117 tzdb::tz_by_name(name).map(TimeZone::Named)
118 }
119
120 pub fn local() -> Option<TimeZone> {
124 tzdb::local_tz().map(TimeZone::Named)
125 }
126
127 pub fn offset_at(self, epoch_utc: f64) -> i32 {
134 match self {
135 TimeZone::Utc => 0,
136 TimeZone::FixedOffset { seconds_east } => seconds_east,
137 TimeZone::Named(tz) => tz
138 .find_local_time_type(epoch_utc.floor() as i64)
139 .map(|lt| lt.ut_offset())
140 .unwrap_or(0),
141 }
142 }
143}
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct DateTime {
152 pub year: i64,
154 pub month: u32,
156 pub day: u32,
158 pub hour: u32,
160 pub minute: u32,
162 pub second: u32,
164 pub microsecond: u32,
166}
167
168fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
174 let y = if m <= 2 { y - 1 } else { y };
176 let era = if y >= 0 { y } else { y - 399 } / 400;
177 let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as i64 + 2) / 5 + d as i64 - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719468
181}
182
183fn civil_from_days(z: i64) -> (i64, u32, u32) {
186 let z = z + 719468;
187 let era = if z >= 0 { z } else { z - 146096 } / 146097;
188 let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
191 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = if m <= 2 { y + 1 } else { y };
196 (y, m, d)
197}
198
199fn days_in_month(year: i64, month: u32) -> u32 {
201 match month {
202 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
203 4 | 6 | 9 | 11 => 30,
204 2 => {
205 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
206 if leap { 29 } else { 28 }
207 }
208 _ => 30,
209 }
210}
211
212impl DateTime {
213 pub fn from_civil(
217 year: i64,
218 month: u32,
219 day: u32,
220 hour: u32,
221 minute: u32,
222 second: u32,
223 microsecond: u32,
224 ) -> Self {
225 let month = month.clamp(1, 12);
226 let day = day.clamp(1, days_in_month(year, month));
227 Self {
228 year,
229 month,
230 day,
231 hour,
232 minute,
233 second,
234 microsecond,
235 }
236 }
237
238 pub fn from_epoch_seconds(epoch: f64) -> Self {
243 let whole = epoch.floor();
246 let frac = epoch - whole; let microsecond = (frac * MICROSECONDS_PER_SECOND).round() as i64;
248 let (mut total_secs, microsecond) = if microsecond >= 1_000_000 {
250 (whole as i64 + 1, 0u32)
251 } else {
252 (whole as i64, microsecond as u32)
253 };
254
255 let days = total_secs.div_euclid(SECONDS_PER_DAY as i64);
256 let sod = total_secs.rem_euclid(SECONDS_PER_DAY as i64); total_secs = sod;
258 let hour = (total_secs / 3600) as u32;
259 let minute = ((total_secs % 3600) / 60) as u32;
260 let second = (total_secs % 60) as u32;
261 let (year, month, day) = civil_from_days(days);
262 Self {
263 year,
264 month,
265 day,
266 hour,
267 minute,
268 second,
269 microsecond,
270 }
271 }
272
273 pub fn to_epoch_seconds(self) -> f64 {
276 let days = days_from_civil(self.year, self.month, self.day);
277 let secs = days * (SECONDS_PER_DAY as i64)
278 + self.hour as i64 * 3600
279 + self.minute as i64 * 60
280 + self.second as i64;
281 secs as f64 + self.microsecond as f64 / MICROSECONDS_PER_SECOND
282 }
283
284 pub fn from_epoch_seconds_tz(epoch: f64, tz: TimeZone) -> Self {
292 DateTime::from_epoch_seconds(epoch + tz.offset_at(epoch) as f64)
293 }
294
295 pub fn to_epoch_seconds_tz(self, tz: TimeZone) -> f64 {
305 let wall = self.to_epoch_seconds();
306 let off1 = tz.offset_at(wall);
307 let off2 = tz.offset_at(wall - off1 as f64);
308 wall - off2 as f64
309 }
310
311 fn get_element(self, unit: DtUnit) -> i64 {
314 match unit {
315 DtUnit::Years => self.year,
316 DtUnit::Months => self.month as i64,
317 DtUnit::Days => self.day as i64,
318 DtUnit::Hours => self.hour as i64,
319 DtUnit::Minutes => self.minute as i64,
320 DtUnit::Seconds => self.second as i64,
321 DtUnit::MicroSeconds => self.microsecond as i64,
322 }
323 }
324
325 fn set_element(self, value: i64, unit: DtUnit) -> Self {
328 let mut dt = self;
329 match unit {
330 DtUnit::Years => dt.year = value,
331 DtUnit::Months => dt.month = value as u32,
332 DtUnit::Days => dt.day = value as u32,
333 DtUnit::Hours => dt.hour = value as u32,
334 DtUnit::Minutes => dt.minute = value as u32,
335 DtUnit::Seconds => dt.second = value as u32,
336 DtUnit::MicroSeconds => dt.microsecond = value as u32,
337 }
338 DateTime::from_civil(
339 dt.year,
340 dt.month,
341 dt.day,
342 dt.hour,
343 dt.minute,
344 dt.second,
345 dt.microsecond,
346 )
347 }
348
349 fn round_to_element(self, unit: DtUnit) -> Self {
353 let u = unit as i64;
354 let month = if u < DtUnit::Months as i64 {
355 1
356 } else {
357 self.month
358 };
359 let day = if u < DtUnit::Days as i64 { 1 } else { self.day };
360 let hour = if u < DtUnit::Hours as i64 {
361 0
362 } else {
363 self.hour
364 };
365 let minute = if u < DtUnit::Minutes as i64 {
366 0
367 } else {
368 self.minute
369 };
370 let second = if u < DtUnit::Seconds as i64 {
371 0
372 } else {
373 self.second
374 };
375 let microsecond = if u < DtUnit::MicroSeconds as i64 {
376 0
377 } else {
378 self.microsecond
379 };
380 DateTime::from_civil(self.year, month, day, hour, minute, second, microsecond)
381 }
382
383 fn add_value(self, value: f64, unit: DtUnit) -> Self {
387 match unit {
388 DtUnit::Years => {
389 let n = value as i64;
390 DateTime::from_civil(
391 self.year + n,
392 self.month,
393 self.day,
394 self.hour,
395 self.minute,
396 self.second,
397 self.microsecond,
398 )
399 }
400 DtUnit::Months => {
401 let n = value as i64;
402 let total = (self.year) * 12 + (self.month as i64 - 1) + n;
406 let year = total.div_euclid(12);
407 let month = (total.rem_euclid(12) + 1) as u32;
408 DateTime::from_civil(
409 year,
410 month,
411 self.day,
412 self.hour,
413 self.minute,
414 self.second,
415 self.microsecond,
416 )
417 }
418 DtUnit::Days => self.add_seconds(value * SECONDS_PER_DAY),
419 DtUnit::Hours => self.add_seconds(value * SECONDS_PER_HOUR),
420 DtUnit::Minutes => self.add_seconds(value * SECONDS_PER_MINUTE),
421 DtUnit::Seconds => self.add_seconds(value),
422 DtUnit::MicroSeconds => self.add_seconds(value / MICROSECONDS_PER_SECOND),
423 }
424 }
425
426 fn add_seconds(self, seconds: f64) -> Self {
428 DateTime::from_epoch_seconds(self.to_epoch_seconds() + seconds)
429 }
430}
431
432fn nice_num_generic(value: f64, nice_fractions: &[f64], is_round: bool) -> f64 {
439 if value == 0.0 {
440 return value;
441 }
442
443 let mut round_fractions: Vec<f64> = nice_fractions.to_vec();
445 if is_round {
446 for i in 0..round_fractions.len().saturating_sub(1) {
447 round_fractions[i] = (nice_fractions[i] + nice_fractions[i + 1]) / 2.0;
448 }
449 }
450
451 let highest = *nice_fractions.last().expect("nice_fractions is non-empty");
452 let value = value.abs();
453 let expvalue = (value.ln() / highest.ln()).floor();
454 let frac = value / highest.powf(expvalue);
455
456 for (nice_frac, round_frac) in nice_fractions.iter().zip(round_fractions.iter()) {
457 if frac <= *round_frac {
458 return nice_frac * highest.powf(expvalue);
459 }
460 }
461 highest * highest.powf(expvalue)
463}
464
465fn nice_date_time_element(value: f64, unit: DtUnit, is_round: bool) -> f64 {
468 let elem = nice_num_generic(value, unit.nice_values(), is_round);
469 if unit == DtUnit::Years || unit == DtUnit::Months {
470 (elem as i64).max(1) as f64
471 } else {
472 elem
473 }
474}
475
476pub fn best_unit(duration_seconds: f64) -> (f64, DtUnit) {
482 if duration_seconds > SECONDS_PER_YEAR * 3.0 {
483 (duration_seconds / SECONDS_PER_YEAR, DtUnit::Years)
484 } else if duration_seconds > SECONDS_PER_MONTH_AVERAGE * 3.0 {
485 (duration_seconds / SECONDS_PER_MONTH_AVERAGE, DtUnit::Months)
486 } else if duration_seconds > SECONDS_PER_DAY * 2.0 {
487 (duration_seconds / SECONDS_PER_DAY, DtUnit::Days)
488 } else if duration_seconds > SECONDS_PER_HOUR * 2.0 {
489 (duration_seconds / SECONDS_PER_HOUR, DtUnit::Hours)
490 } else if duration_seconds > SECONDS_PER_MINUTE * 2.0 {
491 (duration_seconds / SECONDS_PER_MINUTE, DtUnit::Minutes)
492 } else if duration_seconds > 2.0 {
493 (duration_seconds, DtUnit::Seconds)
494 } else {
495 (
496 duration_seconds * MICROSECONDS_PER_SECOND,
497 DtUnit::MicroSeconds,
498 )
499 }
500}
501
502fn find_start_date(d_min: DateTime, d_max: DateTime, n_ticks: usize) -> (DateTime, f64, DtUnit) {
506 let min_epoch = d_min.to_epoch_seconds();
507 let max_epoch = d_max.to_epoch_seconds();
508 debug_assert!(max_epoch >= min_epoch, "d_min should come before d_max");
509
510 if min_epoch == max_epoch {
511 return (d_min, 1.0, DtUnit::MicroSeconds);
513 }
514
515 let length_sec = max_epoch - min_epoch;
516 let (length, unit) = best_unit(length_sec);
517 let nice_length = nice_date_time_element(length, unit, false);
518 let nice_spacing = nice_date_time_element(nice_length / n_ticks as f64, unit, true);
519
520 let d_val = d_min.get_element(unit) as f64;
521
522 let nice_val = if unit == DtUnit::Months || unit == DtUnit::Days {
523 ((d_val - 1.0) / nice_spacing).floor() * nice_spacing + 1.0
524 } else {
525 (d_val / nice_spacing).floor() * nice_spacing
526 };
527
528 let nice_val = if unit == DtUnit::Years && nice_val <= 1.0 {
530 nice_spacing.max(1.0)
531 } else {
532 nice_val
533 };
534
535 let start = d_min.round_to_element(unit);
536 let start = start.set_element(nice_val as i64, unit);
537
538 (start, nice_spacing, unit)
539}
540
541fn date_range(
545 d_min: DateTime,
546 d_max: DateTime,
547 step: f64,
548 unit: DtUnit,
549 include_first_beyond: bool,
550) -> Vec<DateTime> {
551 let step = if unit == DtUnit::Years || unit == DtUnit::Months || unit == DtUnit::MicroSeconds {
553 step.max(1.0)
554 } else {
555 debug_assert!(step > 0.0, "tickstep is 0");
556 step
557 };
558
559 let max_epoch = d_max.to_epoch_seconds();
560 let mut out = Vec::new();
561 let mut dt = d_min;
562 let mut guard = 0usize;
566 while dt.to_epoch_seconds() < max_epoch {
567 out.push(dt);
568 let next = dt.add_value(step, unit);
569 if next.to_epoch_seconds() <= dt.to_epoch_seconds() {
570 dt = next;
573 break;
574 }
575 dt = next;
576 guard += 1;
577 if guard > 1_000_000 {
578 break;
579 }
580 }
581 if include_first_beyond {
582 out.push(dt);
583 }
584 out
585}
586
587pub fn calc_ticks(min: f64, max: f64, n_ticks: usize) -> (Vec<f64>, f64, DtUnit) {
595 calc_ticks_tz(min, max, n_ticks, TimeZone::Utc)
596}
597
598pub fn calc_ticks_tz(min: f64, max: f64, n_ticks: usize, tz: TimeZone) -> (Vec<f64>, f64, DtUnit) {
604 let n_ticks = n_ticks.max(1);
605 let d_min = DateTime::from_epoch_seconds_tz(min, tz);
606 let d_max = DateTime::from_epoch_seconds_tz(max, tz);
607 let (start, spacing, unit) = find_start_date(d_min, d_max, n_ticks);
608 let dates = date_range(start, d_max, spacing, unit, true);
609 let ticks = dates.iter().map(|d| d.to_epoch_seconds_tz(tz)).collect();
610 (ticks, spacing, unit)
611}
612
613pub fn calc_ticks_adaptive(
616 min: f64,
617 max: f64,
618 axis_length: f64,
619 tick_density: f64,
620) -> (Vec<f64>, f64, DtUnit) {
621 calc_ticks_adaptive_tz(min, max, axis_length, tick_density, TimeZone::Utc)
622}
623
624pub fn calc_ticks_adaptive_tz(
627 min: f64,
628 max: f64,
629 axis_length: f64,
630 tick_density: f64,
631 tz: TimeZone,
632) -> (Vec<f64>, f64, DtUnit) {
633 let n = (tick_density * axis_length).round() as i64;
634 let n = n.max(2) as usize;
635 calc_ticks_tz(min, max, n, tz)
636}
637
638fn pad(value: i64, width: usize) -> String {
640 if value < 0 {
641 format!("-{:0width$}", -value, width = width)
642 } else {
643 format!("{value:0width$}")
644 }
645}
646
647pub fn format_tick(epoch: f64, spacing: f64, unit: DtUnit) -> String {
655 format_tick_tz(epoch, spacing, unit, TimeZone::Utc)
656}
657
658pub fn format_tick_tz(epoch: f64, spacing: f64, unit: DtUnit, tz: TimeZone) -> String {
662 let d = DateTime::from_epoch_seconds_tz(epoch, tz);
663 let is_small = spacing < 1.0;
664 match unit {
665 DtUnit::Years => {
666 if is_small {
667 format!("{}-m", pad(d.year, 4))
669 } else {
670 pad(d.year, 4)
671 }
672 }
673 DtUnit::Months => {
674 if is_small {
675 format!(
676 "{}-{}-{}",
677 pad(d.year, 4),
678 pad(d.month as i64, 2),
679 pad(d.day as i64, 2)
680 )
681 } else {
682 format!("{}-{}", pad(d.year, 4), pad(d.month as i64, 2))
683 }
684 }
685 DtUnit::Days => {
686 if is_small {
687 format!("{}:{}", pad(d.hour as i64, 2), pad(d.minute as i64, 2))
688 } else {
689 format!(
690 "{}-{}-{}",
691 pad(d.year, 4),
692 pad(d.month as i64, 2),
693 pad(d.day as i64, 2)
694 )
695 }
696 }
697 DtUnit::Hours => {
698 format!("{}:{}", pad(d.hour as i64, 2), pad(d.minute as i64, 2))
699 }
700 DtUnit::Minutes => {
701 if is_small {
702 format!(
703 "{}:{}:{}",
704 pad(d.hour as i64, 2),
705 pad(d.minute as i64, 2),
706 pad(d.second as i64, 2)
707 )
708 } else {
709 format!("{}:{}", pad(d.hour as i64, 2), pad(d.minute as i64, 2))
710 }
711 }
712 DtUnit::Seconds => {
713 if is_small {
714 format!(
715 "{}.{}",
716 pad(d.second as i64, 2),
717 pad(d.microsecond as i64, 6)
718 )
719 } else {
720 format!(
721 "{}:{}:{}",
722 pad(d.hour as i64, 2),
723 pad(d.minute as i64, 2),
724 pad(d.second as i64, 2)
725 )
726 }
727 }
728 DtUnit::MicroSeconds => {
729 format!(
730 "{}.{}",
731 pad(d.second as i64, 2),
732 pad(d.microsecond as i64, 6)
733 )
734 }
735 }
736}
737
738pub fn format_ticks(ticks: &[f64], spacing: f64, unit: DtUnit) -> Vec<String> {
743 format_ticks_tz(ticks, spacing, unit, TimeZone::Utc)
744}
745
746pub fn format_ticks_tz(ticks: &[f64], spacing: f64, unit: DtUnit, tz: TimeZone) -> Vec<String> {
748 if unit != DtUnit::MicroSeconds {
749 return ticks
750 .iter()
751 .map(|&t| format_tick_tz(t, spacing, unit, tz))
752 .collect();
753 }
754
755 let texts: Vec<String> = ticks
756 .iter()
757 .map(|&t| format_tick_tz(t, spacing, unit, tz))
758 .collect();
759 if texts.is_empty() {
760 return texts;
761 }
762
763 let nzeros = texts
764 .iter()
765 .map(|t| t.len() - t.trim_end_matches('0').len())
766 .min()
767 .unwrap_or(0);
768 let trim = nzeros.min(5);
769
770 texts
771 .iter()
772 .map(|text| {
773 let chars: Vec<char> = text.chars().collect();
774 let start = if chars.first() == Some(&'0') { 1 } else { 0 };
776 let end = chars.len().saturating_sub(trim);
778 if start >= end {
779 String::new()
780 } else {
781 chars[start..end].iter().collect()
782 }
783 })
784 .collect()
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 fn close(a: f64, b: f64, tol: f64) -> bool {
792 (a - b).abs() <= tol
793 }
794
795 #[test]
796 fn civil_epoch_round_trip_known_dates() {
797 let cases = [
800 (0.0_f64, (1970, 1, 1, 0, 0, 0)),
801 (86_400.0, (1970, 1, 2, 0, 0, 0)),
802 (951_827_445.0, (2000, 2, 29, 12, 30, 45)),
804 (1_609_459_200.0, (2021, 1, 1, 0, 0, 0)),
806 (1_709_251_199.0, (2024, 2, 29, 23, 59, 59)),
808 ];
809 for (epoch, (y, m, d, hh, mm, ss)) in cases {
810 let dt = DateTime::from_epoch_seconds(epoch);
811 assert_eq!(
812 (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second),
813 (y, m, d, hh, mm, ss),
814 "decompose {epoch}"
815 );
816 assert!(
818 close(dt.to_epoch_seconds(), epoch, 1e-6),
819 "round-trip {epoch}"
820 );
821 }
822 }
823
824 #[test]
825 fn civil_round_trip_before_epoch() {
826 let dt = DateTime::from_epoch_seconds(-1.0);
828 assert_eq!(
829 (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second),
830 (1969, 12, 31, 23, 59, 59)
831 );
832 assert!(close(dt.to_epoch_seconds(), -1.0, 1e-6));
833 }
834
835 #[test]
836 fn civil_to_epoch_for_leap_day() {
837 let days = days_from_civil(2000, 2, 29);
839 assert_eq!(civil_from_days(days), (2000, 2, 29));
840 let dt = DateTime::from_civil(2000, 2, 29, 0, 0, 0, 0);
841 assert_eq!(dt.to_epoch_seconds(), days as f64 * SECONDS_PER_DAY);
842 }
843
844 #[test]
845 fn best_unit_picks_day_for_a_week() {
846 let (count, unit) = best_unit(7.0 * SECONDS_PER_DAY);
848 assert_eq!(unit, DtUnit::Days);
849 assert!(close(count, 7.0, 1e-9), "count={count}");
850 }
851
852 #[test]
853 fn best_unit_picks_hour_for_six_hours() {
854 let (count, unit) = best_unit(6.0 * SECONDS_PER_HOUR);
856 assert_eq!(unit, DtUnit::Hours);
857 assert!(close(count, 6.0, 1e-9), "count={count}");
858 }
859
860 #[test]
861 fn best_unit_boundary_units() {
862 assert_eq!(best_unit(SECONDS_PER_YEAR * 3.0 + 1.0).1, DtUnit::Years);
864 assert_eq!(
865 best_unit(SECONDS_PER_MONTH_AVERAGE * 3.0 + 1.0).1,
866 DtUnit::Months
867 );
868 assert_eq!(best_unit(SECONDS_PER_DAY * 2.0 + 1.0).1, DtUnit::Days);
869 assert_eq!(best_unit(SECONDS_PER_HOUR * 2.0 + 1.0).1, DtUnit::Hours);
870 assert_eq!(best_unit(SECONDS_PER_MINUTE * 2.0 + 1.0).1, DtUnit::Minutes);
871 assert_eq!(best_unit(2.0 + 0.5).1, DtUnit::Seconds);
872 assert_eq!(best_unit(1.0).1, DtUnit::MicroSeconds);
874 assert_eq!(best_unit(0.0).1, DtUnit::MicroSeconds);
875 }
876
877 #[test]
878 fn calc_ticks_endpoints_bracket_the_range() {
879 let min = DateTime::from_civil(2021, 1, 4, 0, 0, 0, 0).to_epoch_seconds();
881 let max = DateTime::from_civil(2021, 1, 11, 0, 0, 0, 0).to_epoch_seconds();
882 let (ticks, _spacing, unit) = calc_ticks(min, max, 5);
883 assert_eq!(unit, DtUnit::Days);
884 assert!(ticks.len() >= 2, "ticks={ticks:?}");
885 assert!(ticks[0] <= min + 1e-6, "first {} > min {min}", ticks[0]);
887 assert!(
888 *ticks.last().unwrap() >= max - 1e-6,
889 "last {} < max {max}",
890 ticks.last().unwrap()
891 );
892 for w in ticks.windows(2) {
894 assert!(w[1] > w[0], "non-increasing: {:?}", w);
895 }
896 }
897
898 #[test]
899 fn calc_ticks_six_hour_window_uses_hours() {
900 let min = DateTime::from_civil(2021, 6, 1, 8, 0, 0, 0).to_epoch_seconds();
901 let max = DateTime::from_civil(2021, 6, 1, 14, 0, 0, 0).to_epoch_seconds();
902 let (ticks, _spacing, unit) = calc_ticks(min, max, 6);
903 assert_eq!(unit, DtUnit::Hours);
904 assert!(ticks[0] <= min + 1e-6);
905 assert!(*ticks.last().unwrap() >= max - 1e-6);
906 }
907
908 #[test]
909 fn calc_ticks_degenerate_range_is_total() {
910 let t = DateTime::from_civil(2021, 1, 1, 0, 0, 0, 0).to_epoch_seconds();
912 let (ticks, spacing, unit) = calc_ticks(t, t, 5);
913 assert_eq!(unit, DtUnit::MicroSeconds);
914 assert_eq!(spacing, 1.0);
915 assert_eq!(ticks.len(), 1);
917 assert!(close(ticks[0], t, 1e-6));
918 }
919
920 #[test]
921 fn nice_num_generic_matches_default_fractions() {
922 let v = nice_num_generic(1.0, &[1.0, 2.0, 5.0, 10.0], false);
924 assert!(close(v, 1.0, 1e-12), "v={v}");
925 let v = nice_num_generic(7.0, &[1.0, 2.0, 5.0, 10.0], false);
927 assert!(close(v, 10.0, 1e-12), "v={v}");
928 let v = nice_num_generic(3.0, &[1.0, 2.0, 5.0, 10.0], true);
930 assert!(close(v, 2.0, 1e-12), "v={v}");
931 }
932
933 #[test]
934 fn nice_date_time_element_floors_years_to_one() {
935 let v = nice_date_time_element(0.3, DtUnit::Years, true);
937 assert_eq!(v, 1.0);
938 }
939
940 #[test]
941 fn format_tick_day_unit_is_iso_date() {
942 let t = DateTime::from_civil(2021, 3, 9, 13, 5, 0, 0).to_epoch_seconds();
944 assert_eq!(format_tick(t, 1.0, DtUnit::Days), "2021-03-09");
945 }
946
947 #[test]
948 fn format_tick_hours_is_hh_mm() {
949 let t = DateTime::from_civil(2021, 3, 9, 13, 5, 0, 0).to_epoch_seconds();
950 assert_eq!(format_tick(t, 1.0, DtUnit::Hours), "13:05");
951 }
952
953 #[test]
954 fn format_ticks_microseconds_strips_shared_trailing_zeros() {
955 let base = DateTime::from_civil(2021, 1, 1, 0, 0, 0, 100_000).to_epoch_seconds();
958 let t2 = DateTime::from_civil(2021, 1, 1, 0, 0, 0, 200_000).to_epoch_seconds();
959 let out = format_ticks(&[base, t2], 0.5, DtUnit::MicroSeconds);
960 assert_eq!(out, vec!["0.1".to_string(), "0.2".to_string()]);
964 }
965
966 #[test]
967 fn time_zone_offset_at_constant_zones_ignore_instant() {
968 assert_eq!(TimeZone::Utc.offset_at(0.0), 0);
970 assert_eq!(TimeZone::Utc.offset_at(1_700_000_000.0), 0);
971 let jst = TimeZone::FixedOffset {
972 seconds_east: 32400,
973 };
974 assert_eq!(jst.offset_at(0.0), 32400);
975 assert_eq!(jst.offset_at(1_700_000_000.0), 32400);
976 let est = TimeZone::FixedOffset {
977 seconds_east: -18000,
978 };
979 assert_eq!(est.offset_at(0.0), -18000);
980 }
981
982 #[test]
983 fn named_zone_offset_is_dst_aware() {
984 let ny = TimeZone::named("America/New_York").expect("America/New_York in bundled tz db");
985 let winter = DateTime::from_civil(2021, 1, 15, 12, 0, 0, 0).to_epoch_seconds();
987 let summer = DateTime::from_civil(2021, 7, 15, 12, 0, 0, 0).to_epoch_seconds();
988 assert_eq!(ny.offset_at(winter), -18000, "EST should be UTC-5");
989 assert_eq!(ny.offset_at(summer), -14400, "EDT should be UTC-4");
990 assert!(TimeZone::named("Not/AZone").is_none());
992 }
993
994 #[test]
995 fn named_zone_decompose_and_round_trips_both_seasons() {
996 let ny = TimeZone::named("America/New_York").unwrap();
997 let winter_utc = DateTime::from_civil(2021, 1, 15, 12, 0, 0, 0).to_epoch_seconds();
999 let d = DateTime::from_epoch_seconds_tz(winter_utc, ny);
1000 assert_eq!(
1001 (d.year, d.month, d.day, d.hour, d.minute, d.second),
1002 (2021, 1, 15, 7, 0, 0)
1003 );
1004 assert!(close(d.to_epoch_seconds_tz(ny), winter_utc, 1e-6));
1005 let summer_utc = DateTime::from_civil(2021, 7, 15, 12, 0, 0, 0).to_epoch_seconds();
1007 let d = DateTime::from_epoch_seconds_tz(summer_utc, ny);
1008 assert_eq!(
1009 (d.year, d.month, d.day, d.hour, d.minute, d.second),
1010 (2021, 7, 15, 8, 0, 0)
1011 );
1012 assert!(close(d.to_epoch_seconds_tz(ny), summer_utc, 1e-6));
1013 }
1014
1015 #[test]
1016 fn calc_ticks_tz_named_zone_handles_dst_transition() {
1017 let ny = TimeZone::named("America/New_York").unwrap();
1020 let min = DateTime::from_civil(2021, 3, 11, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1021 let max = DateTime::from_civil(2021, 3, 17, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1022 let (ticks, spacing, unit) = calc_ticks_tz(min, max, 5, ny);
1023 assert_eq!(unit, DtUnit::Days);
1024 assert_eq!(spacing, 1.0, "expected a 1-day spacing for this window");
1025 for &t in &ticks {
1027 let d = DateTime::from_epoch_seconds_tz(t, ny);
1028 assert_eq!(
1029 (d.hour, d.minute, d.second),
1030 (0, 0, 0),
1031 "tick {t} not NY midnight: {d:?}"
1032 );
1033 }
1034 let mar14 = DateTime::from_civil(2021, 3, 14, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1039 let mar15 = DateTime::from_civil(2021, 3, 15, 0, 0, 0, 0).to_epoch_seconds_tz(ny);
1040 assert!(
1041 close(mar15 - mar14, 23.0 * 3600.0, 1e-6),
1042 "spring-forward day should be 23h, got {}s",
1043 mar15 - mar14
1044 );
1045 }
1046
1047 #[test]
1048 fn from_to_epoch_tz_applies_offset_and_round_trips() {
1049 let jst = TimeZone::FixedOffset {
1052 seconds_east: 32400,
1053 };
1054 let est = TimeZone::FixedOffset {
1055 seconds_east: -18000,
1056 };
1057
1058 let d = DateTime::from_epoch_seconds_tz(0.0, jst);
1059 assert_eq!(
1060 (d.year, d.month, d.day, d.hour, d.minute, d.second),
1061 (1970, 1, 1, 9, 0, 0)
1062 );
1063 assert!(close(d.to_epoch_seconds_tz(jst), 0.0, 1e-6));
1065
1066 let d = DateTime::from_epoch_seconds_tz(0.0, est);
1067 assert_eq!(
1068 (d.year, d.month, d.day, d.hour, d.minute, d.second),
1069 (1969, 12, 31, 19, 0, 0)
1070 );
1071 assert!(close(d.to_epoch_seconds_tz(est), 0.0, 1e-6));
1072
1073 let d = DateTime::from_epoch_seconds_tz(86_400.0, TimeZone::Utc);
1075 assert_eq!(d, DateTime::from_epoch_seconds(86_400.0));
1076 assert!(close(d.to_epoch_seconds_tz(TimeZone::Utc), 86_400.0, 1e-6));
1077 }
1078
1079 #[test]
1080 fn format_tick_tz_renders_wall_clock_in_zone() {
1081 let epoch = DateTime::from_civil(2021, 3, 9, 13, 5, 0, 0).to_epoch_seconds();
1083 assert_eq!(
1084 format_tick_tz(epoch, 1.0, DtUnit::Hours, TimeZone::Utc),
1085 "13:05"
1086 );
1087 assert_eq!(
1088 format_tick_tz(
1089 epoch,
1090 1.0,
1091 DtUnit::Hours,
1092 TimeZone::FixedOffset {
1093 seconds_east: 32400
1094 }
1095 ),
1096 "22:05"
1097 );
1098 assert_eq!(
1100 format_tick_tz(
1101 DateTime::from_civil(2021, 3, 9, 2, 5, 0, 0).to_epoch_seconds(),
1102 1.0,
1103 DtUnit::Hours,
1104 TimeZone::FixedOffset {
1105 seconds_east: -18000
1106 }
1107 ),
1108 "21:05"
1109 );
1110 }
1111
1112 #[test]
1113 fn calc_ticks_tz_utc_matches_legacy() {
1114 let min = DateTime::from_civil(2021, 1, 4, 0, 0, 0, 0).to_epoch_seconds();
1115 let max = DateTime::from_civil(2021, 1, 11, 0, 0, 0, 0).to_epoch_seconds();
1116 let (a_ticks, a_spacing, a_unit) = calc_ticks(min, max, 5);
1117 let (b_ticks, b_spacing, b_unit) = calc_ticks_tz(min, max, 5, TimeZone::Utc);
1118 assert_eq!(a_ticks, b_ticks);
1119 assert_eq!(a_spacing, b_spacing);
1120 assert_eq!(a_unit, b_unit);
1121 }
1122
1123 #[test]
1124 fn calc_ticks_tz_daily_ticks_land_on_zone_midnight() {
1125 let jst = TimeZone::FixedOffset {
1127 seconds_east: 32400,
1128 };
1129 let min = DateTime::from_civil(2021, 1, 4, 0, 0, 0, 0).to_epoch_seconds_tz(jst);
1130 let max = DateTime::from_civil(2021, 1, 11, 0, 0, 0, 0).to_epoch_seconds_tz(jst);
1131 let (ticks, _spacing, unit) = calc_ticks_tz(min, max, 5, jst);
1132 assert_eq!(unit, DtUnit::Days);
1133 assert!(ticks.len() >= 2, "ticks={ticks:?}");
1134 for &t in &ticks {
1136 let d = DateTime::from_epoch_seconds_tz(t, jst);
1137 assert_eq!(
1138 (d.hour, d.minute, d.second),
1139 (0, 0, 0),
1140 "tick {t} not at zone midnight: {d:?}"
1141 );
1142 }
1143 let labels = format_ticks_tz(&ticks, _spacing, unit, jst);
1145 assert_eq!(labels[0], "2021-01-04");
1146 assert!(ticks[0] <= min + 1e-6);
1147 assert!(*ticks.last().unwrap() >= max - 1e-6);
1148 let (utc_ticks, _, _) = calc_ticks_tz(min, max, 5, TimeZone::Utc);
1151 assert_ne!(ticks, utc_ticks);
1152 }
1153}