Skip to main content

ggplot_rs/stat/
calendar.rs

1//! Calendar heatmap layout (`stat_calendar` / `GGPlot::geom_calendar`): a date
2//! column becomes week-column × weekday-row tiles.
3
4use crate::aes::Aesthetic;
5use crate::data::{DataFrame, Value};
6use crate::scale::ScaleSet;
7
8use super::Stat;
9
10/// Longest span laid out, in years. Older dates are clipped (with a build
11/// warning from `geom_calendar`) so a stray 1900-01-01 cannot blow up the
12/// layout or allocate thousands of month boundaries.
13pub const MAX_CALENDAR_YEARS: i64 = 50;
14const MAX_SPAN_DAYS: i64 = MAX_CALENDAR_YEARS * 366;
15
16/// Column holding each cell's ISO date (`YYYY-MM-DD`); `geom_tile` uses it as
17/// the hover key (`data-x` and tooltip) instead of the week/weekday position.
18pub const DATE_KEY_COL: &str = ".key";
19
20/// Days since 1970-01-01 for a date value: `DateTime` (epoch seconds), a
21/// number (epoch seconds, as `DateTime` coerces), or an ISO `YYYY-MM-DD…`
22/// string. `None` for anything else / out of range.
23pub fn day_number(v: &Value) -> Option<i64> {
24    const LIMIT: f64 = 1.0e14; // ~3 million years of seconds: plenty, no overflow
25    match v {
26        Value::DateTime(s) => Some(s.div_euclid(86_400)),
27        Value::Integer(i) => Some(i.div_euclid(86_400)),
28        Value::Float(f) if f.is_finite() && f.abs() < LIMIT => Some((f / 86_400.0).floor() as i64),
29        Value::Str(s) => parse_iso_date(s),
30        _ => None,
31    }
32}
33
34fn parse_iso_date(s: &str) -> Option<i64> {
35    let s = s.trim();
36    let date = s.get(..10)?;
37    let mut it = date.split('-');
38    let y: i64 = it.next()?.parse().ok()?;
39    let m: u32 = it.next()?.parse().ok()?;
40    let d: u32 = it.next()?.parse().ok()?;
41    if !(1..=12).contains(&m) || !(1..=31).contains(&d) || !(-9999..=9999).contains(&y) {
42        return None;
43    }
44    Some(days_from_civil(y, m, d))
45}
46
47/// Days since 1970-01-01 of a civil date (Hinnant).
48pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
49    let y = if m <= 2 { y - 1 } else { y };
50    let era = if y >= 0 { y } else { y - 399 } / 400;
51    let yoe = y - era * 400;
52    let m = m as i64;
53    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
54    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
55    era * 146_097 + doe - 719_468
56}
57
58/// Civil `(year, month, day)` of a day number (Hinnant).
59pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
60    let z = z + 719_468;
61    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
62    let doe = z - era * 146_097;
63    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
64    let y = yoe + era * 400;
65    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
66    let mp = (5 * doy + 2) / 153;
67    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
68    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
69    (if m <= 2 { y + 1 } else { y }, m, d)
70}
71
72/// Weekday of a day number, 0-based from the week start (Sunday or Monday).
73pub fn weekday(day: i64, monday_first: bool) -> i64 {
74    // 1970-01-01 was a Thursday (Sunday-based 4, Monday-based 3).
75    (day + if monday_first { 3 } else { 4 }).rem_euclid(7)
76}
77
78/// The calendar grid shared by [`StatCalendar`] and `geom_calendar`: which
79/// day is column 0 / row 0, and the (clipped) first/last day.
80#[derive(Clone, Copy, Debug)]
81pub struct CalendarGrid {
82    /// First day of week 0 (a Sunday, or a Monday when `monday_first`).
83    pub origin: i64,
84    pub first_day: i64,
85    pub last_day: i64,
86    pub monday_first: bool,
87}
88
89impl CalendarGrid {
90    /// Grid over the given day numbers, clipped to the last
91    /// [`MAX_CALENDAR_YEARS`] years. Returns the grid and whether it clipped.
92    pub fn from_days(days: impl Iterator<Item = i64>, monday_first: bool) -> Option<(Self, bool)> {
93        let (mut lo, mut hi) = (i64::MAX, i64::MIN);
94        for d in days {
95            lo = lo.min(d);
96            hi = hi.max(d);
97        }
98        if lo > hi {
99            return None;
100        }
101        let clipped = hi - lo > MAX_SPAN_DAYS;
102        let lo = lo.max(hi - MAX_SPAN_DAYS);
103        Some((
104            CalendarGrid {
105                origin: lo - weekday(lo, monday_first),
106                first_day: lo,
107                last_day: hi,
108                monday_first,
109            },
110            clipped,
111        ))
112    }
113
114    /// Week column of a day.
115    pub fn column(&self, day: i64) -> i64 {
116        (day - self.origin).div_euclid(7)
117    }
118
119    /// Row position of a day: the first weekday is the top row (y = 6).
120    pub fn row(&self, day: i64) -> i64 {
121        6 - weekday(day, self.monday_first)
122    }
123
124    pub fn n_weeks(&self) -> i64 {
125        self.column(self.last_day) + 1
126    }
127
128    pub fn contains(&self, day: i64) -> bool {
129        (self.first_day..=self.last_day).contains(&day)
130    }
131}
132
133/// Lays a date `x` out as calendar cells: `x` = week column, `y` = weekday
134/// row (first weekday on top, y = 6 … 0), unit `xmin`/`xmax`/`ymin`/`ymax`
135/// extents, and the ISO date in [`DATE_KEY_COL`]. Other columns (`fill`,
136/// `label`, …) pass through. Dates older than [`MAX_CALENDAR_YEARS`] before
137/// the newest are dropped.
138#[derive(Clone, Debug, Default)]
139pub struct StatCalendar {
140    /// Fix the grid (so separately computed groups/guides share it); `None`
141    /// derives it from the group's own dates.
142    pub grid: Option<CalendarGrid>,
143    /// Weeks start on Monday instead of Sunday.
144    pub monday_first: bool,
145}
146
147impl Stat for StatCalendar {
148    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
149        let Some(x) = data.column("x") else {
150            return DataFrame::new();
151        };
152        let days: Vec<Option<i64>> = x.iter().map(day_number).collect();
153        let grid = match self.grid {
154            Some(g) => g,
155            None => {
156                match CalendarGrid::from_days(days.iter().flatten().copied(), self.monday_first) {
157                    Some((g, _)) => g,
158                    None => return DataFrame::new(),
159                }
160            }
161        };
162        let rows: Vec<(usize, i64)> = days
163            .iter()
164            .enumerate()
165            .filter_map(|(i, d)| d.filter(|d| grid.contains(*d)).map(|d| (i, d)))
166            .collect();
167        let mut out = DataFrame::new();
168        let col = |off: f64, row: bool| -> Vec<Value> {
169            rows.iter()
170                .map(|&(_, d)| {
171                    let base = if row { grid.row(d) } else { grid.column(d) };
172                    Value::Float(base as f64 + off)
173                })
174                .collect()
175        };
176        out.add_column("x".to_string(), col(0.0, false));
177        out.add_column("y".to_string(), col(0.0, true));
178        out.add_column("xmin".to_string(), col(-0.5, false));
179        out.add_column("xmax".to_string(), col(0.5, false));
180        out.add_column("ymin".to_string(), col(-0.5, true));
181        out.add_column("ymax".to_string(), col(0.5, true));
182        out.add_column(
183            DATE_KEY_COL.to_string(),
184            rows.iter()
185                .map(|&(_, d)| {
186                    let (y, m, dd) = civil_from_days(d);
187                    Value::Str(format!("{y:04}-{m:02}-{dd:02}"))
188                })
189                .collect(),
190        );
191        for name in data.column_names() {
192            if out.has_column(name) {
193                continue;
194            }
195            if let Some(src) = data.column(name) {
196                out.add_column(
197                    name.to_string(),
198                    rows.iter().map(|&(i, _)| src[i].clone()).collect(),
199                );
200            }
201        }
202        out
203    }
204
205    fn required_aes(&self) -> Vec<Aesthetic> {
206        vec![Aesthetic::X]
207    }
208
209    fn name(&self) -> &str {
210        "calendar"
211    }
212}
213
214const MONTHS: [&str; 12] = [
215    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
216];
217
218/// Month-start x breaks/labels for a calendar grid: the week column of each
219/// month's first day. Over two years, only Januaries are labelled (with the
220/// year); a multi-year range adds the year to each January.
221pub fn month_breaks(grid: &CalendarGrid) -> (Vec<f64>, Vec<String>) {
222    let starts = month_starts(grid);
223    let (y0, _, _) = civil_from_days(grid.first_day);
224    let (y1, _, _) = civil_from_days(grid.last_day);
225    let many = starts.len() > 24;
226    let mut breaks = Vec::new();
227    let mut labels = Vec::new();
228    for (day, y, m) in starts {
229        if many && m != 1 {
230            continue;
231        }
232        breaks.push(grid.column(day) as f64);
233        labels.push(if many {
234            y.to_string()
235        } else if y0 != y1 && m == 1 {
236            format!("{} {y}", MONTHS[0])
237        } else {
238            MONTHS[(m - 1) as usize].to_string()
239        });
240    }
241    (breaks, labels)
242}
243
244/// First days of each month intersecting the grid (the first visible day for
245/// the first month), as `(day, year, month)`.
246fn month_starts(grid: &CalendarGrid) -> Vec<(i64, i64, u32)> {
247    let (mut y, mut m, _) = civil_from_days(grid.first_day);
248    let mut out = vec![(grid.first_day, y, m)];
249    loop {
250        (y, m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
251        let d = days_from_civil(y, m, 1);
252        if d > grid.last_day {
253            break;
254        }
255        out.push((d, y, m));
256    }
257    out
258}
259
260/// Month boundary segments `(x, y, xend, yend)` separating consecutive
261/// months (GitHub/ECharts style step outlines), in calendar coordinates.
262pub fn month_boundaries(grid: &CalendarGrid) -> Vec<(f64, f64, f64, f64)> {
263    let mut segs = Vec::new();
264    for (day, _, _) in month_starts(grid).into_iter().skip(1) {
265        let c = grid.column(day) as f64;
266        let w = weekday(day, grid.monday_first) as f64;
267        let row_top = 6.5 - w;
268        if w > 0.0 {
269            segs.push((c + 0.5, 6.5, c + 0.5, row_top));
270            segs.push((c + 0.5, row_top, c - 0.5, row_top));
271        }
272        segs.push((c - 0.5, row_top, c - 0.5, -0.5));
273    }
274    segs
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn civil_roundtrip() {
283        for d in [-1_000_000, -1, 0, 1, 19_000, 2_000_000] {
284            let (y, m, dd) = civil_from_days(d);
285            assert_eq!(days_from_civil(y, m, dd), d);
286        }
287        assert_eq!(days_from_civil(1970, 1, 1), 0);
288        assert_eq!(weekday(0, false), 4); // Thursday
289        assert_eq!(weekday(0, true), 3);
290    }
291
292    #[test]
293    fn huge_span_is_clipped() {
294        let (g, clipped) =
295            CalendarGrid::from_days([-5_000_000, 20_000].into_iter(), false).unwrap();
296        assert!(clipped);
297        assert!(g.n_weeks() <= MAX_SPAN_DAYS / 7 + 2);
298        assert!(month_starts(&g).len() <= (MAX_CALENDAR_YEARS as usize + 1) * 12 + 1);
299    }
300}