Skip to main content

charts_rs/charts/
calendar_chart.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12
13use super::Canvas;
14use super::canvas;
15use super::color::*;
16use super::common::*;
17use super::component::*;
18use super::params::*;
19use super::theme::{DEFAULT_Y_AXIS_WIDTH, Theme, get_default_theme_name, get_theme};
20use super::util::*;
21use crate::charts::measure_text_width_family;
22use charts_rs_derive::Chart;
23use std::sync::Arc;
24
25// ── Simple date helpers (no external crate) ──────────────────────────────────
26
27fn is_leap_year(year: i32) -> bool {
28    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
29}
30
31fn days_in_month(year: i32, month: u32) -> u32 {
32    match month {
33        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
34        4 | 6 | 9 | 11 => 30,
35        2 => {
36            if is_leap_year(year) {
37                29
38            } else {
39                28
40            }
41        }
42        _ => 0,
43    }
44}
45
46/// Returns day-of-week: 0 = Sunday, 1 = Monday, …, 6 = Saturday.
47/// Uses Tomohiko Sakamoto's algorithm.
48fn day_of_week(year: i32, month: u32, day: u32) -> u32 {
49    let t: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
50    let y = if month < 3 { year - 1 } else { year };
51    ((y + y / 4 - y / 100 + y / 400 + t[(month - 1) as usize] + day as i32).rem_euclid(7)) as u32
52}
53
54/// Parses "YYYY-MM-DD" → (year, month, day).  Returns None on malformed input.
55fn parse_date(s: &str) -> Option<(i32, u32, u32)> {
56    let mut parts = s.splitn(3, '-');
57    let year: i32 = parts.next()?.parse().ok()?;
58    let month: u32 = parts.next()?.parse().ok()?;
59    let day: u32 = parts.next()?.parse().ok()?;
60    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
61        return None;
62    }
63    Some((year, month, day))
64}
65
66/// Julian Day Number – used only to compute day differences.
67fn jdn(year: i32, month: u32, day: u32) -> i64 {
68    let a = (14_i64 - month as i64) / 12;
69    let y = year as i64 + 4800 - a;
70    let m = month as i64 + 12 * a - 3;
71    day as i64 + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
72}
73
74/// Number of days from `(y1,m1,d1)` to `(y2,m2,d2)` (negative if before).
75fn days_diff(y1: i32, m1: u32, d1: u32, y2: i32, m2: u32, d2: u32) -> i64 {
76    jdn(y2, m2, d2) - jdn(y1, m1, d1)
77}
78
79/// Advance a date by `n` days (n ≥ 0).
80fn add_days(mut year: i32, mut month: u32, mut day: u32, mut n: u32) -> (i32, u32, u32) {
81    while n > 0 {
82        let dim = days_in_month(year, month);
83        let remaining = dim - day;
84        if n <= remaining {
85            day += n;
86            n = 0;
87        } else {
88            n -= remaining + 1;
89            day = 1;
90            month += 1;
91            if month > 12 {
92                month = 1;
93                year += 1;
94            }
95        }
96    }
97    (year, month, day)
98}
99
100fn current_year() -> i32 {
101    // std::time gives seconds since Unix epoch; derive the year without external crates.
102    let secs = std::time::SystemTime::now()
103        .duration_since(std::time::UNIX_EPOCH)
104        .map(|d| d.as_secs())
105        .unwrap_or(0);
106    // Rough: 400-year Gregorian cycle = 146_097 days = 12_622_780_800 s
107    let days = (secs / 86_400) as i64;
108    // Shift to 1970-01-01; use the same JDN approach as jdn()
109    let jdn = days + 2_440_588; // JDN of 1970-01-01
110    let a = jdn + 32_044;
111    let b = (4 * a + 3) / 146_097;
112    let c = a - (146_097 * b) / 4;
113    let d = (4 * c + 3) / 1_461;
114    let e = c - (1_461 * d) / 4;
115    let m = (5 * e + 2) / 153;
116    let year = 100 * b + d - 4_800 + m / 10;
117    year as i32
118}
119
120static MONTH_ABBR: [&str; 12] = [
121    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
122];
123static DOW_ABBR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
124
125// ── CalendarChart ─────────────────────────────────────────────────────────────
126
127#[derive(Clone, Debug, Default, Chart)]
128pub struct CalendarChart {
129    pub width: f32,
130    pub height: f32,
131    pub x: f32,
132    pub y: f32,
133    pub margin: Box,
134    // dummy – required by #[derive(Chart)]
135    series_list: Vec<Series>,
136    pub font_family: String,
137    pub background_color: Color,
138    pub is_light: bool,
139
140    // title
141    pub title_text: String,
142    pub title_font_size: f32,
143    pub title_font_color: Color,
144    pub title_font_weight: Option<String>,
145    pub title_margin: Option<Box>,
146    pub title_align: Align,
147    pub title_height: f32,
148
149    // sub title
150    pub sub_title_text: String,
151    pub sub_title_font_size: f32,
152    pub sub_title_font_color: Color,
153    pub sub_title_font_weight: Option<String>,
154    pub sub_title_margin: Option<Box>,
155    pub sub_title_align: Align,
156    pub sub_title_height: f32,
157
158    // legend (required by derive, not rendered)
159    pub legend_font_size: f32,
160    pub legend_font_color: Color,
161    pub legend_font_weight: Option<String>,
162    pub legend_align: Align,
163    pub legend_margin: Option<Box>,
164    pub legend_category: LegendCategory,
165    pub legend_show: Option<bool>,
166
167    // x/y axis fields required by derive (not used in rendering)
168    pub x_axis_data: Vec<String>,
169    pub x_axis_height: f32,
170    pub x_axis_stroke_color: Color,
171    pub x_axis_font_size: f32,
172    pub x_axis_font_color: Color,
173    pub x_axis_font_weight: Option<String>,
174    pub x_axis_name_gap: f32,
175    pub x_axis_name_rotate: f32,
176    pub x_axis_margin: Option<Box>,
177    pub x_axis_hidden: bool,
178    pub x_boundary_gap: Option<bool>,
179
180    pub y_axis_hidden: bool,
181    y_axis_configs: Vec<YAxisConfig>,
182
183    grid_stroke_color: Color,
184    grid_stroke_width: f32,
185
186    // series fields required by derive
187    pub series_stroke_width: f32,
188    pub series_label_font_color: Color,
189    pub series_label_font_size: f32,
190    pub series_label_font_weight: Option<String>,
191    pub series_label_formatter: String,
192    pub series_colors: Vec<Color>,
193    pub series_symbol: Option<Symbol>,
194    pub series_smooth: bool,
195    pub series_fill: bool,
196
197    // ── Calendar-specific fields ──────────────────────────────────────────────
198    /// Data points: each entry is `("YYYY-MM-DD", value)`.
199    pub data: Vec<(String, f32)>,
200
201    /// First day shown, inclusive.  Format: `"YYYY-MM-DD"`.
202    pub start_date: String,
203
204    /// Last day shown, inclusive.  Format: `"YYYY-MM-DD"`.
205    pub end_date: String,
206
207    /// Value that maps to `min_color`.  Auto-computed from data when 0.
208    pub min: f32,
209
210    /// Value that maps to `max_color`.  Auto-computed from data when 0.
211    pub max: f32,
212
213    /// Color for cells with the minimum value (default: light gray).
214    pub min_color: Color,
215
216    /// Color for cells with the maximum value (default: green).
217    pub max_color: Color,
218
219    /// Color for cells that have no data entry.
220    pub empty_color: Color,
221
222    /// Side length of each day square in pixels (default: 13).
223    pub cell_size: f32,
224
225    /// Gap between adjacent squares in pixels (default: 3).
226    pub cell_gap: f32,
227
228    /// Height of the month-label row at the top of the grid (default: 20).
229    pub month_label_height: f32,
230
231    /// Width of the day-of-week label column at the left of the grid (default: 30).
232    pub week_label_width: f32,
233
234    /// Rows of the day-of-week labels to display.
235    /// Each entry is a day-of-week index (0 = Sun … 6 = Sat).
236    /// Defaults to `[1, 3, 5]` (Mon, Wed, Fri), matching the GitHub style.
237    pub show_dow_labels: Vec<usize>,
238}
239
240impl CalendarChart {
241    fn fill_default(&mut self) {
242        if self.cell_size <= 0.0 {
243            self.cell_size = 13.0;
244        }
245        if self.cell_gap <= 0.0 {
246            self.cell_gap = 3.0;
247        }
248        if self.month_label_height <= 0.0 {
249            self.month_label_height = 20.0;
250        }
251        if self.week_label_width <= 0.0 {
252            self.week_label_width = 30.0;
253        }
254        if self.show_dow_labels.is_empty() {
255            self.show_dow_labels = vec![1, 3, 5]; // Mon, Wed, Fri
256        }
257        // default colors – GitHub contribution-graph palette
258        if self.min_color.is_zero() {
259            self.min_color = (235, 237, 240).into();
260        }
261        if self.max_color.is_zero() {
262            self.max_color = (33, 110, 57).into();
263        }
264        if self.empty_color.is_zero() {
265            let mut c: Color = if self.is_light {
266                (235, 237, 240).into()
267            } else {
268                (40, 40, 45).into()
269            };
270            c = c.with_alpha(180);
271            self.empty_color = c;
272        }
273        // auto min / max from data when not explicitly set
274        if self.min == 0.0 && self.max == 0.0 && !self.data.is_empty() {
275            let mut lo = f32::MAX;
276            let mut hi = f32::MIN;
277            for (_, v) in &self.data {
278                if *v < lo {
279                    lo = *v;
280                }
281                if *v > hi {
282                    hi = *v;
283                }
284            }
285            self.min = lo.min(0.0);
286            self.max = hi.max(0.0);
287        }
288        // default date range: current year
289        let current_year = current_year();
290        if self.start_date.is_empty() {
291            self.start_date = format!("{current_year}-01-01");
292        }
293        if self.end_date.is_empty() {
294            self.end_date = format!("{current_year}-12-31");
295        }
296    }
297
298    /// Interpolate between min_color and max_color.
299    fn cell_color(&self, value: f32) -> Color {
300        let value = value.clamp(self.min, self.max);
301        let range = self.max - self.min;
302        if range <= 0.0 {
303            return self.max_color;
304        }
305        let t = (value - self.min) / range;
306        let lerp = |a: u8, b: u8| -> u8 {
307            let diff = (b as f32 - a as f32) * t;
308            (a as f32 + diff).round() as u8
309        };
310        Color {
311            r: lerp(self.min_color.r, self.max_color.r),
312            g: lerp(self.min_color.g, self.max_color.g),
313            b: lerp(self.min_color.b, self.max_color.b),
314            a: lerp(self.min_color.a, self.max_color.a),
315        }
316    }
317
318    /// Creates a calendar chart for `year` (Jan 1 – Dec 31) with default theme.
319    pub fn new(data: Vec<(String, f32)>, year: i32) -> CalendarChart {
320        CalendarChart::new_with_theme(data, year, &get_default_theme_name())
321    }
322
323    /// Creates a calendar chart for `year` with a custom theme.
324    pub fn new_with_theme(data: Vec<(String, f32)>, year: i32, theme: &str) -> CalendarChart {
325        let mut c = CalendarChart {
326            data,
327            start_date: format!("{year:04}-01-01"),
328            end_date: format!("{year:04}-12-31"),
329            ..Default::default()
330        };
331        let t = get_theme(theme);
332        c.fill_theme(t);
333        c.fill_default();
334        // Auto-size to fit the calendar
335        c.width = c.auto_width();
336        c.height = c.auto_height();
337        c
338    }
339
340    /// Creates a calendar chart from a JSON string.
341    pub fn from_json(json: &str) -> canvas::Result<CalendarChart> {
342        let mut c = CalendarChart {
343            ..Default::default()
344        };
345        let value = c.fill_option(json)?;
346        if let Some(start) = get_string_from_value(&value, "start_date") {
347            c.start_date = start;
348        }
349        if let Some(end) = get_string_from_value(&value, "end_date") {
350            c.end_date = end;
351        }
352        if let Some(min) = get_f32_from_value(&value, "min") {
353            c.min = min;
354        }
355        if let Some(max) = get_f32_from_value(&value, "max") {
356            c.max = max;
357        }
358        if let Some(col) = get_color_from_value(&value, "min_color") {
359            c.min_color = col;
360        }
361        if let Some(col) = get_color_from_value(&value, "max_color") {
362            c.max_color = col;
363        }
364        if let Some(col) = get_color_from_value(&value, "empty_color") {
365            c.empty_color = col;
366        }
367        if let Some(v) = get_f32_from_value(&value, "cell_size") {
368            c.cell_size = v;
369        }
370        if let Some(v) = get_f32_from_value(&value, "cell_gap") {
371            c.cell_gap = v;
372        }
373        if let Some(v) = get_f32_from_value(&value, "month_label_height") {
374            c.month_label_height = v;
375        }
376        if let Some(v) = get_f32_from_value(&value, "week_label_width") {
377            c.week_label_width = v;
378        }
379        if let Some(arr) = value.get("show_dow_labels").and_then(|v| v.as_array()) {
380            c.show_dow_labels = arr
381                .iter()
382                .filter_map(|v| v.as_u64().map(|n| n as usize))
383                .collect();
384        }
385        // parse data: [[date_str, value], ...]
386        if let Some(arr) = value.get("data").and_then(|v| v.as_array()) {
387            let mut items = vec![];
388            for item in arr {
389                if let Some(pair) = item.as_array()
390                    && pair.len() == 2
391                    && let (Some(date), Some(val)) = (pair[0].as_str(), pair[1].as_f64())
392                {
393                    items.push((date.to_string(), val as f32));
394                }
395            }
396            c.data = items;
397        }
398        c.fill_default();
399        // CalendarChart always auto-sizes: layout is driven by cell_size/cell_gap,
400        // not by a fixed canvas width. Users control size via those fields instead.
401        c.width = c.auto_width();
402        c.height = c.auto_height();
403        Ok(c)
404    }
405
406    /// Computes the number of week-columns needed for the current date range.
407    fn num_weeks(&self) -> usize {
408        let (sy, sm, sd) = match parse_date(&self.start_date) {
409            Some(d) => d,
410            None => return 53,
411        };
412        let (ey, em, ed) = match parse_date(&self.end_date) {
413            Some(d) => d,
414            None => return 53,
415        };
416        let total_days = days_diff(sy, sm, sd, ey, em, ed) + 1;
417        if total_days <= 0 {
418            return 1;
419        }
420        let start_dow = day_of_week(sy, sm, sd) as i64; // 0=Sun
421        ((start_dow + total_days + 6) / 7) as usize
422    }
423
424    fn auto_width(&self) -> f32 {
425        let step = self.cell_size + self.cell_gap;
426        self.margin.left
427            + self.margin.right
428            + self.week_label_width
429            + self.num_weeks() as f32 * step
430    }
431
432    fn auto_height(&self) -> f32 {
433        let step = self.cell_size + self.cell_gap;
434        let title_h = if !self.title_text.is_empty() {
435            self.title_height
436                + if !self.sub_title_text.is_empty() {
437                    self.sub_title_height
438                } else {
439                    0.0
440                }
441        } else {
442            0.0
443        };
444        self.margin.top + self.margin.bottom + title_h + self.month_label_height + 7.0 * step
445    }
446
447    /// Renders the calendar heatmap to an SVG string.
448    pub fn svg(&self) -> canvas::Result<String> {
449        let (sy, sm, sd) = parse_date(&self.start_date).ok_or_else(|| canvas::Error::Params {
450            message: format!("invalid start_date: {}", self.start_date),
451        })?;
452        let (ey, em, ed) = parse_date(&self.end_date).ok_or_else(|| canvas::Error::Params {
453            message: format!("invalid end_date: {}", self.end_date),
454        })?;
455
456        let total_days = days_diff(sy, sm, sd, ey, em, ed) + 1;
457        if total_days <= 0 {
458            return Err(canvas::Error::Params {
459                message: "end_date must be >= start_date".to_string(),
460            });
461        }
462
463        let start_dow = day_of_week(sy, sm, sd) as i64; // 0=Sun
464
465        // Build lookup: date-string → value
466        let mut lookup = std::collections::HashMap::new();
467        for (date_str, val) in &self.data {
468            lookup.insert(date_str.as_str(), *val);
469        }
470
471        let mut c = Canvas::new_width_xy(self.width, self.height, self.x, self.y);
472        self.render_background(c.child(Box::default()));
473        c.margin = self.margin.clone();
474
475        let top_offset = self.render_title(c.child(Box::default()));
476
477        // Canvas region below the title
478        let mut grid_c = c.child(Box {
479            top: top_offset,
480            ..Default::default()
481        });
482
483        let step = self.cell_size + self.cell_gap;
484        let wlw = self.week_label_width; // left column width
485        let mlh = self.month_label_height; // top row height
486
487        // ── Day-of-week labels ────────────────────────────────────────────────
488        let dow_font_size = self.x_axis_font_size.max(10.0);
489        let dow_color = self.x_axis_font_color;
490        for &row in &self.show_dow_labels {
491            let label = DOW_ABBR[row % 7];
492            let y = mlh + row as f32 * step + self.cell_size / 2.0;
493            grid_c.text(Text {
494                text: label.to_string(),
495                font_family: Some(self.font_family.clone()),
496                font_color: Some(dow_color),
497                font_size: Some(dow_font_size),
498                dominant_baseline: Some("central".to_string()),
499                x: Some(0.0),
500                y: Some(y),
501                ..Default::default()
502            });
503        }
504
505        // ── Month labels ──────────────────────────────────────────────────────
506        // We track which week each month starts in.
507        let month_font_size = self.x_axis_font_size.max(10.0);
508        let month_color = self.x_axis_font_color;
509        let mut cur_y = sy;
510        let mut cur_m = sm;
511        let mut cur_d = sd;
512        let mut last_month_col: Option<u32> = None;
513        for day_idx in 0..total_days {
514            let col = ((start_dow + day_idx) / 7) as u32;
515            // Is this the first day of a new month within the visible range?
516            if cur_d == 1 && last_month_col != Some(col) {
517                let label = MONTH_ABBR[(cur_m - 1) as usize];
518                let x = wlw + col as f32 * step;
519                grid_c.text(Text {
520                    text: label.to_string(),
521                    font_family: Some(self.font_family.clone()),
522                    font_color: Some(month_color),
523                    font_size: Some(month_font_size),
524                    dominant_baseline: Some("auto".to_string()),
525                    x: Some(x),
526                    y: Some(mlh - 2.0),
527                    ..Default::default()
528                });
529                last_month_col = Some(col);
530            }
531            // Advance date by one day
532            let next = add_days(cur_y, cur_m, cur_d, 1);
533            cur_y = next.0;
534            cur_m = next.1;
535            cur_d = next.2;
536        }
537
538        // ── Day cells ─────────────────────────────────────────────────────────
539        let mut cy = sy;
540        let mut cm = sm;
541        let mut cd = sd;
542        for day_idx in 0..total_days {
543            let col = ((start_dow + day_idx) / 7) as usize;
544            let row = ((start_dow + day_idx) % 7) as usize;
545
546            let date_str = format!("{cy:04}-{cm:02}-{cd:02}");
547            let color = if let Some(&val) = lookup.get(date_str.as_str()) {
548                self.cell_color(val)
549            } else {
550                self.empty_color
551            };
552
553            let x = wlw + col as f32 * step;
554            let y = mlh + row as f32 * step;
555
556            grid_c.rect(Rect {
557                color: Some(color),
558                fill: Some(color.into()),
559                left: x,
560                top: y,
561                width: self.cell_size,
562                height: self.cell_size,
563                rx: Some(2.0),
564                ry: Some(2.0),
565                ..Default::default()
566            });
567
568            let next = add_days(cy, cm, cd, 1);
569            cy = next.0;
570            cm = next.1;
571            cd = next.2;
572        }
573
574        c.svg()
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::CalendarChart;
581    use pretty_assertions::assert_eq;
582
583    fn make_data() -> Vec<(String, f32)> {
584        vec![
585            ("2024-01-05".to_string(), 2.0),
586            ("2024-01-10".to_string(), 5.0),
587            ("2024-01-15".to_string(), 3.0),
588            ("2024-02-14".to_string(), 8.0),
589            ("2024-03-20".to_string(), 6.0),
590            ("2024-04-01".to_string(), 1.0),
591            ("2024-06-15".to_string(), 9.0),
592            ("2024-09-01".to_string(), 4.0),
593            ("2024-12-25".to_string(), 10.0),
594        ]
595    }
596
597    #[test]
598    fn calendar_chart_basic() {
599        let chart = CalendarChart::new(make_data(), 2024);
600        assert_eq!(
601            include_str!("../../asset/calendar_chart/basic.svg"),
602            chart.svg().unwrap()
603        );
604    }
605
606    #[test]
607    fn calendar_chart_basic_json() {
608        let chart = CalendarChart::from_json(
609            r##"{
610                "start_date": "2024-01-01",
611                "end_date": "2024-12-31",
612                "title_text": "2024 Contributions",
613                "cell_size": 11,
614                "cell_gap": 2,
615                "max_color": "#216e39",
616                "min_color": "#ebedf0",
617                "data": [
618                    ["2024-01-05", 2],
619                    ["2024-02-14", 8],
620                    ["2024-06-15", 9],
621                    ["2024-09-01", 4],
622                    ["2024-12-25", 10]
623                ]
624            }"##,
625        )
626        .unwrap();
627        assert_eq!(
628            include_str!("../../asset/calendar_chart/basic_json.svg"),
629            chart.svg().unwrap()
630        );
631    }
632}