chartml_core/scales/
time.rs1use chrono::NaiveDateTime;
2use super::tick_step;
3
4pub struct ScaleTime {
7 domain: (NaiveDateTime, NaiveDateTime),
8 range: (f64, f64),
9}
10
11impl ScaleTime {
12 pub fn new(domain: (NaiveDateTime, NaiveDateTime), range: (f64, f64)) -> Self {
14 Self { domain, range }
15 }
16
17 pub fn map(&self, value: &NaiveDateTime) -> f64 {
19 let t = self.to_timestamp(value);
20 let (t0, t1) = self.domain_timestamps();
21 let (r0, r1) = self.range;
22 let domain_span = t1 - t0;
23 if domain_span == 0.0 {
24 return (r0 + r1) / 2.0;
25 }
26 r0 + (t - t0) / domain_span * (r1 - r0)
27 }
28
29 pub fn invert(&self, value: f64) -> NaiveDateTime {
31 let (t0, t1) = self.domain_timestamps();
32 let (r0, r1) = self.range;
33 let range_span = r1 - r0;
34 let t = if range_span == 0.0 {
35 (t0 + t1) / 2.0
36 } else {
37 t0 + (value - r0) / range_span * (t1 - t0)
38 };
39 self.secs_to_datetime(t)
40 }
41
42 pub fn ticks(&self, count: usize) -> Vec<NaiveDateTime> {
46 if count == 0 {
47 return vec![];
48 }
49 let (t0, t1) = self.domain_timestamps();
50 let min = t0.min(t1);
51 let max = t0.max(t1);
52 if min == max {
53 return vec![self.secs_to_datetime(min)];
54 }
55
56 let step = tick_step(min, max, count);
57 if step == 0.0 || !step.is_finite() {
58 return vec![];
59 }
60
61 let mut ticks = Vec::new();
62 let start = (min / step).ceil();
63 let stop = (max / step).floor();
64
65 let mut i = start;
66 while i <= stop {
67 let tick = i * step;
68 ticks.push(self.secs_to_datetime(tick));
69 i += 1.0;
70 }
71
72 if t0 > t1 {
73 ticks.reverse();
74 }
75
76 ticks
77 }
78
79 pub fn domain(&self) -> (NaiveDateTime, NaiveDateTime) {
81 self.domain
82 }
83
84 pub fn range(&self) -> (f64, f64) {
86 self.range
87 }
88
89 fn to_timestamp(&self, dt: &NaiveDateTime) -> f64 {
90 dt.and_utc().timestamp() as f64
91 }
92
93 fn domain_timestamps(&self) -> (f64, f64) {
94 (
95 self.to_timestamp(&self.domain.0),
96 self.to_timestamp(&self.domain.1),
97 )
98 }
99
100 fn secs_to_datetime(&self, secs: f64) -> NaiveDateTime {
101 let secs = (secs as i64).clamp(-8_334_632_851_200, 8_210_298_412_799);
103 chrono::DateTime::from_timestamp(secs, 0)
104 .unwrap_or_else(|| {
105 chrono::DateTime::from_timestamp(0, 0).expect("epoch timestamp 0 is always valid")
107 })
108 .naive_utc()
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 #![allow(clippy::unwrap_used)]
115 use super::*;
116 use chrono::NaiveDate;
117
118 fn make_dt(year: i32, month: u32, day: u32) -> NaiveDateTime {
119 NaiveDate::from_ymd_opt(year, month, day)
120 .unwrap()
121 .and_hms_opt(0, 0, 0)
122 .unwrap()
123 }
124
125 #[test]
126 fn time_scale_maps_midpoint() {
127 let start = make_dt(2024, 1, 1);
128 let end = make_dt(2024, 1, 31);
129 let mid = make_dt(2024, 1, 16);
130 let scale = ScaleTime::new((start, end), (0.0, 100.0));
131 let result = scale.map(&mid);
132 assert!(
133 (result - 50.0).abs() < 2.0,
134 "midpoint should map close to 50, got {}",
135 result
136 );
137 }
138
139 #[test]
140 fn time_scale_maps_endpoints() {
141 let start = make_dt(2024, 1, 1);
142 let end = make_dt(2024, 1, 31);
143 let scale = ScaleTime::new((start, end), (0.0, 100.0));
144 assert!(
145 (scale.map(&start) - 0.0).abs() < 1e-10,
146 "start should map to 0"
147 );
148 assert!(
149 (scale.map(&end) - 100.0).abs() < 1e-10,
150 "end should map to 100"
151 );
152 }
153
154 #[test]
155 fn time_scale_inverts() {
156 let start = make_dt(2024, 1, 1);
157 let end = make_dt(2024, 1, 31);
158 let scale = ScaleTime::new((start, end), (0.0, 100.0));
159 let mid_value = 50.0;
160 let inverted = scale.invert(mid_value);
161 let mid = make_dt(2024, 1, 16);
163 let diff_secs = (inverted.and_utc().timestamp() - mid.and_utc().timestamp()).abs();
164 assert!(
165 diff_secs < 86400, "inverted date should be close to midpoint, got {:?}",
167 inverted
168 );
169 }
170
171 #[test]
172 fn time_scale_ticks() {
173 let start = make_dt(2024, 1, 1);
174 let end = make_dt(2024, 12, 31);
175 let scale = ScaleTime::new((start, end), (0.0, 100.0));
176 let ticks = scale.ticks(5);
177 assert!(
178 !ticks.is_empty(),
179 "should generate at least one tick"
180 );
181 assert!(
182 ticks.len() <= 15,
183 "should not generate too many ticks, got {}",
184 ticks.len()
185 );
186 for tick in &ticks {
188 assert!(*tick >= start, "tick {:?} should be >= start", tick);
189 assert!(*tick <= end, "tick {:?} should be <= end", tick);
190 }
191 }
192}