Skip to main content

chartml_core/scales/
time.rs

1use chrono::NaiveDateTime;
2use super::tick_step;
3
4/// Maps a temporal domain (NaiveDateTime) to a continuous range via linear interpolation on timestamps.
5/// Equivalent to D3's `scaleUtc()`.
6pub struct ScaleTime {
7    domain: (NaiveDateTime, NaiveDateTime),
8    range: (f64, f64),
9}
10
11impl ScaleTime {
12    /// Create a new time scale with the given domain and range.
13    pub fn new(domain: (NaiveDateTime, NaiveDateTime), range: (f64, f64)) -> Self {
14        Self { domain, range }
15    }
16
17    /// Map a datetime to a range value via linear interpolation on timestamps.
18    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    /// Inverse: range value back to NaiveDateTime.
30    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    /// Generate nice time ticks.
43    /// Strategy: convert to timestamps, use linear tick algorithm on the timestamp space,
44    /// then convert back to NaiveDateTime.
45    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    /// Get the domain extent.
80    pub fn domain(&self) -> (NaiveDateTime, NaiveDateTime) {
81        self.domain
82    }
83
84    /// Get the range extent.
85    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        // Clamp to valid chrono timestamp range to avoid panics
102        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                // Fallback to epoch if somehow still out of range
106                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        // The inverted value should be roughly the middle date
162        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, // within 1 day
166            "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        // All ticks should be within the domain
187        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}