1use std::collections::HashMap;
42
43use chrono::Datelike;
44use chrono::Duration;
45use chrono::NaiveDate;
46use image::ImageBuffer;
47use image::Rgba;
48use imageproc::drawing::draw_filled_rect_mut;
49use imageproc::rect::Rect;
50
51pub mod builtins;
52
53pub trait MappingStrategy {
55 fn map(&self, count: u32, max_count: u32, num_colors: usize) -> usize;
57}
58
59pub struct Palette {
61 colors: Vec<Rgba<u8>>,
62 strategy: Box<dyn MappingStrategy>,
63}
64
65impl Palette {
66 pub fn new(colors: Vec<Rgba<u8>>, strategy: impl MappingStrategy + 'static) -> Self {
68 Self {
69 colors,
70 strategy: Box::new(strategy),
71 }
72 }
73
74 pub fn get_color(&self, count: u32, max_count: u32) -> Rgba<u8> {
76 let index = self.strategy.map(count, max_count, self.colors.len());
77 self.colors[index]
78 }
79}
80
81pub struct ContributionGraph {
83 data: HashMap<NaiveDate, u32>,
84 start_date: Option<NaiveDate>,
85 end_date: Option<NaiveDate>,
86 box_size: u32,
87 gap: u32,
88 margin: u32,
89 palette: Palette,
90 background_color: Rgba<u8>,
91 round_corners: bool,
92}
93
94impl Default for ContributionGraph {
95 fn default() -> Self {
96 Self {
97 data: HashMap::new(),
98 start_date: None,
99 end_date: None,
100 box_size: 11,
101 gap: 3,
102 margin: 20,
103 palette: builtins::Theme::github(builtins::Strategy::linear()),
104 background_color: Rgba([0, 0, 0, 0]),
105 round_corners: true,
106 }
107 }
108}
109
110impl ContributionGraph {
111 pub fn new() -> Self {
113 Self::default()
114 }
115
116 pub fn with_data(mut self, data: HashMap<NaiveDate, u32>) -> Self {
120 self.data = data;
121 self
122 }
123
124 pub fn start_date(mut self, date: NaiveDate) -> Self {
128 self.start_date = Some(date);
129 self
130 }
131
132 pub fn end_date(mut self, date: NaiveDate) -> Self {
136 self.end_date = Some(date);
137 self
138 }
139
140 pub fn box_size(mut self, size: u32) -> Self {
142 self.box_size = size;
143 self
144 }
145
146 pub fn gap(mut self, gap: u32) -> Self {
148 self.gap = gap;
149 self
150 }
151
152 pub fn margin(mut self, margin: u32) -> Self {
154 self.margin = margin;
155 self
156 }
157
158 pub fn theme(mut self, palette: Palette) -> Self {
185 self.palette = palette;
186 self
187 }
188
189 pub fn background_color(mut self, color: Rgba<u8>) -> Self {
191 self.background_color = color;
192 self
193 }
194
195 pub fn round_corners(mut self, round: bool) -> Self {
197 self.round_corners = round;
198 self
199 }
200
201 pub fn generate(&self) -> ImageBuffer<Rgba<u8>, Vec<u8>> {
203 let (start, end) = self.calculate_date_range();
204 let (width, height) = self.calculate_dimensions(start, end);
205
206 let max_count = self.data.values().cloned().max().unwrap_or(0);
207
208 let mut img = ImageBuffer::from_pixel(width, height, self.background_color);
209
210 let mut curr = start;
211 while curr <= end {
212 let count = self.data.get(&curr).cloned().unwrap_or(0);
213 let color = self.palette.get_color(count, max_count);
214
215 let (x, y) = self.get_coordinates(curr, start);
216 self.draw_cell(&mut img, x, y, color);
217
218 curr += Duration::days(1);
219 }
220
221 img
222 }
223
224 fn calculate_date_range(&self) -> (NaiveDate, NaiveDate) {
226 let current_year = chrono::Utc::now().naive_utc().year();
227 let start = self.start_date.unwrap_or_else(|| {
228 if self.data.is_empty() {
229 NaiveDate::from_ymd_opt(current_year, 1, 1).unwrap()
230 } else {
231 *self.data.keys().min().unwrap()
232 }
233 });
234
235 let end = self.end_date.unwrap_or_else(|| {
236 if self.data.is_empty() {
237 NaiveDate::from_ymd_opt(current_year, 12, 31).unwrap()
238 } else {
239 *self.data.keys().max().unwrap()
240 }
241 });
242
243 (start, end)
244 }
245
246 fn calculate_dimensions(&self, start: NaiveDate, end: NaiveDate) -> (u32, u32) {
248 let start_weekday = start.weekday().num_days_from_sunday() as i32;
249 let total_days = (end - start).num_days() as i32 + 1;
250 let weeks = (total_days + start_weekday + 6) / 7;
251
252 let width = self.margin * 2 + (weeks as u32) * (self.box_size + self.gap) - self.gap;
253 let height = self.margin * 2 + 7 * (self.box_size + self.gap) - self.gap;
254
255 (width, height)
256 }
257
258 fn get_coordinates(&self, date: NaiveDate, start: NaiveDate) -> (i32, i32) {
260 let start_weekday = start.weekday().num_days_from_sunday() as i32;
261 let day_idx = date.weekday().num_days_from_sunday() as i32;
262 let days_from_start = (date - start).num_days() as i32;
263 let week_idx = (days_from_start + start_weekday) / 7;
264
265 let x = self.margin as i32 + week_idx * (self.box_size as i32 + self.gap as i32);
266 let y = self.margin as i32 + day_idx * (self.box_size as i32 + self.gap as i32);
267
268 (x, y)
269 }
270
271 fn draw_cell(&self, img: &mut ImageBuffer<Rgba<u8>, Vec<u8>>, x: i32, y: i32, color: Rgba<u8>) {
273 let width = img.width();
274 let height = img.height();
275
276 if x >= 0
277 && y >= 0
278 && (x as u32 + self.box_size) <= width
279 && (y as u32 + self.box_size) <= height
280 {
281 draw_filled_rect_mut(
282 img,
283 Rect::at(x, y).of_size(self.box_size, self.box_size),
284 color,
285 );
286
287 if self.round_corners && color[3] > 0 {
288 let corner_color = self.background_color;
289 if (x as u32) < width && (y as u32) < height {
290 img.put_pixel(x as u32, y as u32, corner_color);
291 }
292 if (x as u32 + self.box_size - 1) < width && (y as u32) < height {
293 img.put_pixel(
294 (x + self.box_size as i32 - 1) as u32,
295 y as u32,
296 corner_color,
297 );
298 }
299 if (x as u32) < width && (y as u32 + self.box_size - 1) < height {
300 img.put_pixel(
301 x as u32,
302 (y + self.box_size as i32 - 1) as u32,
303 corner_color,
304 );
305 }
306 if (x as u32 + self.box_size - 1) < width && (y as u32 + self.box_size - 1) < height
307 {
308 img.put_pixel(
309 (x + self.box_size as i32 - 1) as u32,
310 (y + self.box_size as i32 - 1) as u32,
311 corner_color,
312 );
313 }
314 }
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_linear_mapping() {
325 let strategy = builtins::LinearStrategy;
326 assert_eq!(strategy.map(0, 100, 5), 0);
328 assert_eq!(strategy.map(100, 100, 5), 4);
330 assert_eq!(strategy.map(50, 100, 5), 2);
332 }
333
334 #[test]
335 fn test_threshold_mapping() {
336 let strategy = builtins::ThresholdStrategy::new(vec![1, 5, 10]);
337 assert_eq!(strategy.map(0, 0, 4), 0);
338 assert_eq!(strategy.map(3, 0, 4), 1);
339 assert_eq!(strategy.map(8, 0, 4), 2);
340 assert_eq!(strategy.map(15, 0, 4), 3);
341 }
342
343 #[test]
344 fn test_github_theme_colors() {
345 let palette = builtins::Theme::github(builtins::Strategy::linear());
346 assert_eq!(palette.get_color(0, 100), Rgba([235, 237, 240, 255]));
347 assert_eq!(palette.get_color(100, 100), Rgba([33, 110, 57, 255]));
348 }
349
350 #[test]
351 fn test_date_range_calculation() {
352 let graph = ContributionGraph::new();
353 let (start, end) = graph.calculate_date_range();
354 let year = chrono::Utc::now().naive_utc().year();
355 assert_eq!(start, NaiveDate::from_ymd_opt(year, 1, 1).unwrap());
356 assert_eq!(end, NaiveDate::from_ymd_opt(year, 12, 31).unwrap());
357 }
358
359 #[test]
360 fn test_dimensions_calculation() {
361 let start = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
362 let end = NaiveDate::from_ymd_opt(2023, 1, 7).unwrap();
363 let graph = ContributionGraph::new().box_size(10).gap(2).margin(20);
364 let (width, height) = graph.calculate_dimensions(start, end);
365 assert_eq!(width, 50);
366 assert_eq!(height, 122);
367 }
368}