yew_chart/
time_axis_scale.rs

1/// A TimeAxisScale represents a linear scale for timestamps within a fixed range.
2/// A step duration is also expressed and indicates the interval to be used for each tick on the axis.
3use chrono::TimeZone;
4use chrono::{DateTime, Duration, Local, Utc};
5use std::{ops::Range, rc::Rc};
6
7use crate::axis::{NormalisedValue, Scale, Tick};
8
9/// An axis labeller is a closure that produces a string given a value within the axis scale
10pub trait Labeller: Fn(i64) -> String {}
11
12impl<T: Fn(i64) -> String> Labeller for T {}
13
14fn local_time_labeller(format: &'static str) -> impl Labeller {
15    move |ts| {
16        let utc_date_time = Utc.timestamp_millis_opt(ts).unwrap();
17        let local_date_time: DateTime<Local> = utc_date_time.into();
18        local_date_time.format(format).to_string()
19    }
20}
21
22#[derive(Clone)]
23pub struct TimeScale {
24    time: Range<i64>,
25    step: i64,
26    scale: f32,
27    labeller: Option<Rc<dyn Labeller>>,
28}
29
30impl TimeScale {
31    /// Create a new scale with a range and step representing labels as a day and month in local time.
32    pub fn new(range: Range<DateTime<Utc>>, step: Duration) -> TimeScale {
33        Self::with_local_time_labeller(range, step, "%d-%b")
34    }
35
36    /// Create a new scale with a range and step and local time labeller with a supplied format.
37    pub fn with_local_time_labeller(
38        range: Range<DateTime<Utc>>,
39        step: Duration,
40        format: &'static str,
41    ) -> TimeScale {
42        Self::with_labeller(range, step, Some(Rc::from(local_time_labeller(format))))
43    }
44
45    /// Create a new scale with a range and step and custom labeller.
46    pub fn with_labeller(
47        range: Range<DateTime<Utc>>,
48        step: Duration,
49        labeller: Option<Rc<dyn Labeller>>,
50    ) -> TimeScale {
51        let time_from = range.start.timestamp_millis();
52        let time_to = range.end.timestamp_millis();
53        let delta = time_to - time_from;
54        let scale = if delta != 0 { 1.0 / delta as f32 } else { 1.0 };
55        let step = step.num_milliseconds();
56
57        TimeScale {
58            time: time_from..time_to,
59            step,
60            scale,
61            labeller,
62        }
63    }
64}
65
66impl Scale for TimeScale {
67    type Scalar = i64;
68
69    fn ticks(&self) -> Vec<Tick> {
70        TimeScaleInclusiveIter {
71            time_from: self.time.start,
72            time_to: self.time.end,
73            step: self.step,
74            first_time: true,
75        }
76        .map(move |i| {
77            let location = (i - self.time.start) as f32 * self.scale;
78            Tick {
79                location: NormalisedValue(location),
80                label: self.labeller.as_ref().map(|l| (l)(i)),
81            }
82        })
83        .collect()
84    }
85
86    fn normalise(&self, value: Self::Scalar) -> NormalisedValue {
87        NormalisedValue((value - self.time.start) as f32 * self.scale)
88    }
89}
90
91struct TimeScaleInclusiveIter {
92    pub time_from: i64,
93    pub time_to: i64,
94    pub step: i64,
95    pub first_time: bool,
96}
97
98impl Iterator for TimeScaleInclusiveIter {
99    type Item = i64;
100
101    fn next(&mut self) -> Option<Self::Item> {
102        let time = if !self.first_time {
103            self.time_from.checked_add(self.step).map(|time| {
104                self.time_from = time;
105                time
106            })
107        } else {
108            self.first_time = false;
109            Some(self.time_from)
110        };
111        match self.step {
112            s if s > 0 => time.filter(|t| *t <= self.time_to),
113            s if s < 0 => time.filter(|t| *t >= self.time_to),
114            _ => None,
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    use std::ops::Sub;
124
125    #[test]
126    fn test_scale() {
127        let end_date = Local
128            .with_ymd_and_hms(2022, 3, 2, 16, 56, 0)
129            .single()
130            .unwrap();
131        let start_date = end_date.sub(Duration::days(4));
132        let range = start_date.into()..end_date.into();
133        let scale = TimeScale::new(range, Duration::days(1));
134
135        assert_eq!(
136            scale.ticks(),
137            vec![
138                Tick {
139                    location: NormalisedValue(0.0),
140                    label: Some("26-Feb".to_string())
141                },
142                Tick {
143                    location: NormalisedValue(0.25),
144                    label: Some("27-Feb".to_string())
145                },
146                Tick {
147                    location: NormalisedValue(0.5),
148                    label: Some("28-Feb".to_string())
149                },
150                Tick {
151                    location: NormalisedValue(0.75),
152                    label: Some("01-Mar".to_string())
153                },
154                Tick {
155                    location: NormalisedValue(1.0),
156                    label: Some("02-Mar".to_string())
157                }
158            ]
159        );
160
161        assert_eq!(
162            scale.normalise(end_date.sub(Duration::days(2)).timestamp_millis()),
163            NormalisedValue(0.5)
164        );
165    }
166
167    #[test]
168    fn test_backward_scale() {
169        let start_date = Local
170            .with_ymd_and_hms(2022, 3, 2, 16, 56, 0)
171            .single()
172            .unwrap();
173        let end_date = start_date.sub(Duration::days(4));
174        let range = start_date.into()..end_date.into();
175        let scale = TimeScale::new(range, Duration::days(-1));
176
177        assert_eq!(
178            scale.ticks(),
179            vec![
180                Tick {
181                    location: NormalisedValue(0.0),
182                    label: Some("02-Mar".to_string())
183                },
184                Tick {
185                    location: NormalisedValue(0.25),
186                    label: Some("01-Mar".to_string())
187                },
188                Tick {
189                    location: NormalisedValue(0.5),
190                    label: Some("28-Feb".to_string())
191                },
192                Tick {
193                    location: NormalisedValue(0.75),
194                    label: Some("27-Feb".to_string())
195                },
196                Tick {
197                    location: NormalisedValue(1.0),
198                    label: Some("26-Feb".to_string())
199                }
200            ]
201        );
202
203        assert_eq!(
204            scale.normalise(start_date.sub(Duration::days(2)).timestamp_millis()),
205            NormalisedValue(0.5)
206        );
207    }
208
209    #[test]
210    fn test_zero_range() {
211        let end_date = Local
212            .with_ymd_and_hms(2022, 3, 2, 16, 56, 0)
213            .single()
214            .unwrap();
215        let start_date = end_date;
216        let range = start_date.into()..end_date.into();
217        let scale = TimeScale::new(range, Duration::days(1));
218
219        assert_eq!(
220            scale.ticks(),
221            vec![Tick {
222                location: NormalisedValue(0.0),
223                label: Some("02-Mar".to_string())
224            },]
225        );
226
227        assert_eq!(
228            scale.normalise(end_date.timestamp_millis()),
229            NormalisedValue(0.0)
230        );
231    }
232
233    #[test]
234    fn test_zero_step() {
235        let end_date = Local
236            .with_ymd_and_hms(2022, 3, 2, 16, 56, 0)
237            .single()
238            .unwrap();
239        let start_date = end_date;
240        let range = start_date.into()..end_date.into();
241        let scale = TimeScale::new(range, Duration::days(0));
242
243        assert_eq!(scale.ticks(), vec![]);
244
245        assert_eq!(
246            scale.normalise(end_date.timestamp_millis()),
247            NormalisedValue(0.0)
248        );
249    }
250}