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).unwrap()
107            })
108            .naive_utc()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use chrono::NaiveDate;
116
117    fn make_dt(year: i32, month: u32, day: u32) -> NaiveDateTime {
118        NaiveDate::from_ymd_opt(year, month, day)
119            .unwrap()
120            .and_hms_opt(0, 0, 0)
121            .unwrap()
122    }
123
124    #[test]
125    fn time_scale_maps_midpoint() {
126        let start = make_dt(2024, 1, 1);
127        let end = make_dt(2024, 1, 31);
128        let mid = make_dt(2024, 1, 16);
129        let scale = ScaleTime::new((start, end), (0.0, 100.0));
130        let result = scale.map(&mid);
131        assert!(
132            (result - 50.0).abs() < 2.0,
133            "midpoint should map close to 50, got {}",
134            result
135        );
136    }
137
138    #[test]
139    fn time_scale_maps_endpoints() {
140        let start = make_dt(2024, 1, 1);
141        let end = make_dt(2024, 1, 31);
142        let scale = ScaleTime::new((start, end), (0.0, 100.0));
143        assert!(
144            (scale.map(&start) - 0.0).abs() < 1e-10,
145            "start should map to 0"
146        );
147        assert!(
148            (scale.map(&end) - 100.0).abs() < 1e-10,
149            "end should map to 100"
150        );
151    }
152
153    #[test]
154    fn time_scale_inverts() {
155        let start = make_dt(2024, 1, 1);
156        let end = make_dt(2024, 1, 31);
157        let scale = ScaleTime::new((start, end), (0.0, 100.0));
158        let mid_value = 50.0;
159        let inverted = scale.invert(mid_value);
160        // The inverted value should be roughly the middle date
161        let mid = make_dt(2024, 1, 16);
162        let diff_secs = (inverted.and_utc().timestamp() - mid.and_utc().timestamp()).abs();
163        assert!(
164            diff_secs < 86400, // within 1 day
165            "inverted date should be close to midpoint, got {:?}",
166            inverted
167        );
168    }
169
170    #[test]
171    fn time_scale_ticks() {
172        let start = make_dt(2024, 1, 1);
173        let end = make_dt(2024, 12, 31);
174        let scale = ScaleTime::new((start, end), (0.0, 100.0));
175        let ticks = scale.ticks(5);
176        assert!(
177            !ticks.is_empty(),
178            "should generate at least one tick"
179        );
180        assert!(
181            ticks.len() <= 15,
182            "should not generate too many ticks, got {}",
183            ticks.len()
184        );
185        // All ticks should be within the domain
186        for tick in &ticks {
187            assert!(*tick >= start, "tick {:?} should be >= start", tick);
188            assert!(*tick <= end, "tick {:?} should be <= end", tick);
189        }
190    }
191}