1use 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
25fn 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
46fn 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
54fn 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
66fn 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
74fn 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
79fn 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 let secs = std::time::SystemTime::now()
103 .duration_since(std::time::UNIX_EPOCH)
104 .map(|d| d.as_secs())
105 .unwrap_or(0);
106 let days = (secs / 86_400) as i64;
108 let jdn = days + 2_440_588; 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#[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 series_list: Vec<Series>,
136 pub font_family: String,
137 pub background_color: Color,
138 pub is_light: bool,
139
140 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 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 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 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 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 pub data: Vec<(String, f32)>,
200
201 pub start_date: String,
203
204 pub end_date: String,
206
207 pub min: f32,
209
210 pub max: f32,
212
213 pub min_color: Color,
215
216 pub max_color: Color,
218
219 pub empty_color: Color,
221
222 pub cell_size: f32,
224
225 pub cell_gap: f32,
227
228 pub month_label_height: f32,
230
231 pub week_label_width: f32,
233
234 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]; }
257 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 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 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 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 pub fn new(data: Vec<(String, f32)>, year: i32) -> CalendarChart {
320 CalendarChart::new_with_theme(data, year, &get_default_theme_name())
321 }
322
323 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 c.width = c.auto_width();
336 c.height = c.auto_height();
337 c
338 }
339
340 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 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 c.width = c.auto_width();
402 c.height = c.auto_height();
403 Ok(c)
404 }
405
406 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; ((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 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; 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 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; let mlh = self.month_label_height; 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 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 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 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 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}