heatmap_svg 0.1.1

a package to make svg image heatmaps based on a list of dates
Documentation
use std::collections::HashMap;
use std::thread::spawn;
use chrono::{Datelike, Local, NaiveDate};
use svg::node::element::{Rectangle, Text};
use svg::Document;

/// a representation of our heatmap, all the fields are corresponding settings
/// you can manually create this struct but it is reccomended to be done with [Heatmap::new()]
/// as it creates a default template, which you can then edit to your liking
pub struct Heatmap<'a> {
		/// the background color
		pub bg_color : &'a str,
		/// the foreground color
		pub fg_color : &'a str,
		/// color for cells who have a lower frequency then [Heatmap::tier1_limit]
		pub empty_cell_color : &'a str,
		/// color for cells who have a higher frequency then [Heatmap::tier1_limit]
		pub tier1_cell_color : &'a str,
		/// color for cells who have a higher frequency then [Heatmap::tier2_limit]
		pub tier2_cell_color : &'a str,
		/// color for cells who have a higher frequency then [Heatmap::tier3_limit]
		pub tier3_cell_color : &'a str,
		/// color for cells who have a higher frequency then [Heatmap::tier4_limit]
		pub tier4_cell_color : &'a str,
		/// limit for when a day qualifies as tier 1, this impacts the color of this day in the heatmap
		pub tier1_limit : u32,
		/// limit for when a day qualifies as tier 2, this impacts the color of this day in the heatmap
		pub tier2_limit : u32,
		/// limit for when a day qualifies as tier 3, this impacts the color of this day in the heatmap
		pub tier3_limit : u32,
		/// limit for when a day qualifies as tier 4, this impacts the color of this day in the heatmap
		pub tier4_limit : u32,
		/// the size of the cells in the heatmap
		pub size : usize,
		/// the spacing between cells of the heatmap
		pub spacing : usize,
		/// the space between the left edge of the image and the position of the heatmap (not the legend)
		pub left_offset : usize,
		/// the space between the upper edge of the image and the position of the heatmap (not the legend)
		pub up_offset : usize,
		/// describes the period over which heatmap should overview, for possible values see [Span]
		pub span : Span,
		/// describes the starting point of the heatmap, for possible values see [StartDate]
		pub start_date : StartDate
}
/// an enum describing the startig point of the heatmap
pub enum StartDate {
		/// start from today on
		Today,
		/// start from the earliest point over the period, so say we look over a week, we then start on Monday, for a month on the 1st of the month, and for a year
		/// it will start on year-01-01. for [Span::Custom] it will just take the first day it can
		StartPeriod,
		/// start from a custom day
		Custom(NaiveDate)
}

/// the period over which the heatmap should span over
pub enum Span {
		/// span over a week, or more specifically, 7 days
		Week,
		/// span over a month starting from the 1st day of the month, if Today or custom is selected, it will take the amount of days in
		/// in the startdays month
		Month,
		/// span over a year, specifically 365 days on non leap years, 366 on leap years
		Year,
		/// span over a set amount of days, note that this will make [StartDate::Today] and [StartDate::StartPeriod] do the same thing
		/// also heatmap_svg does not support spanning over more then 1 year yet, this will be changed in later versions
		Custom(u32)
}

impl<'a> Heatmap<'a> {
		/// this will give back a default template, editing stuff should be done by changing the struct field around
		/// it default to a catppuccin colorpallete, and a handpicked sizing meant for a year
		pub fn new() -> Heatmap<'a> {
				Heatmap {
						// ui settings
						bg_color: "#1E1E2E",
						fg_color: "#CDD6F4",
						empty_cell_color: "#45475a",
						tier1_cell_color: "#94e2d5",
						tier2_cell_color: "#89dceb",
						tier3_cell_color: "#74c7ec",
						tier4_cell_color: "#b4befe",
						tier1_limit: 0,
						tier2_limit: 3,
						tier3_limit: 6,
						tier4_limit: 10,
						size: 20,
						spacing: 5,
						left_offset: 300,
						up_offset: 50,
						// behavioral settings
						span : Span::Year,
						start_date : StartDate::StartPeriod
				}
		}
		/// this creates an svg image and writes it to the path given
		pub fn create_svg(&self, input : Vec<NaiveDate>, save_path : &str) {
				let startdate =  self.get_start_date();
				let span = self.get_span();
				let doc : Document = self.create_field(input, startdate, span);
				svg::save(save_path, &doc).unwrap();
		}

		/// composes the svg image based on the given imput and the settings of its struct
		fn create_field(&self, input : Vec<NaiveDate>, startdate : u32, span : u32) -> Document {
				let week_count = u32::div_ceil(span, 7);
				let week_num = startdate / 7 as u32;
				// bg rectangle serves as the background of the image
				let bg = Rectangle::new()
						.set("fill", self.bg_color)
						.set("width", "100%")
						.set("height", "100%");
				// we make the document that we plan to return here
				let mut doc = Document::new()
						.add(bg);

				// these labels are the legend that tells us what color means what in the heatmap
				let limits = ["≤".to_owned() + &self.tier1_limit.to_string(),
											">".to_owned() + &self.tier1_limit.to_string(),
											">".to_owned() + &self.tier2_limit.to_string(),
											">".to_owned() + &self.tier3_limit.to_string(),
											">".to_owned() + &self.tier4_limit.to_string()
				];
				// an array of colors used to easily iterate to prevent code duplications
				let colors = [self.empty_cell_color, self.tier1_cell_color, self.tier2_cell_color, self.tier3_cell_color, self.tier4_cell_color];
				// we enumerate over the limits, since the size of the color and limit array is the same allowing us to get the corresponding color
				// for each limit
				for (i, limit) in limits.iter().enumerate() {
						// here we create the square that shows us what color we use
						let rect = Rectangle::new()
								.set("y", (i+1) * (self.size + self.spacing) as usize)
								.set("x", 12)
								.set("width", format!("{}px", self.size))
								.set("height", format!("{}px", self.size))
								.set("fill", colors[i]);
						// here we make our label
						let label = Text::new(limit)
								.set("y", 15 + (i+1) * (self.size + self.spacing) as usize)
								.set("x", 12 + self.spacing + self.size )
								.set("fill", self.fg_color);
						// and add them to the doc
						doc = doc.add(rect).add(label);
				}
				
				// week counter
				// there are 53 colloms every year (51-52 full weeks and then 1-2 segmented weeks)
				for i in week_num..week_count {
						// simply generating labels that number every collum
						let pos = i - week_num;
						let lbl = Text::new((pos).to_string())
								.set("fill", self.fg_color)
								.set("x", self.left_offset + pos as usize * (self.size + self.spacing))
								.set("y", self.up_offset);
						doc = doc.add(lbl);
				}
				// day labels, used for iteration
				let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
				
				for (i, day) in days.into_iter().enumerate() {
						// displaying the day for every row
						let lbl = Text::new(day)
								.set("fill", self.fg_color)
								.set("y", self.up_offset  + (i+1) * (self.size + self.spacing) as usize)
								.set("x", self.left_offset / 2);
						doc = doc.add(lbl);
				}
				// self.dates_to_frequency_map() makes a hashmap mapping every day number to it's frequency
				let freq = self.dates_to_frequency_map(input);

				// we get the position of the weekdays string representation in our days array, and then offset our heatmaps square generation
				// so that all days will be on the corrrect position (works due to a weeks consistent size)
				let offset = match self.start_date {
						StartDate::Custom(date) => days.iter().position(|i| i ==  &date.weekday().to_string()).unwrap(),
						_ => days.iter().position(|i| i ==  &NaiveDate::from_yo_opt(Local::now().year(), startdate + 1).unwrap().weekday().to_string()).unwrap()
				};

				// generating the heatmap aspect
				for i in startdate..(startdate + span) {
						let pos = (i - startdate);
						// we make the x and y positions, we read from up -> down and left -> right
						let x = (((pos + offset as u32)/7) as usize) as i32;
						let y = ((pos as usize + offset) % 7) as i32;
						println!("i = {}; x = {}; y = {}; pos = {}; date = {}", i, x, y, pos, NaiveDate::from_num_days_from_ce_opt(i as i32).unwrap().to_string());
						// translate the positions in the grid to coordinates
						let xcoord = self.left_offset + x as usize * (self.size + self.spacing);
						let ycoord = self.up_offset + 10 + y as usize * (self.size + self.spacing);
						let color : &str;
						// check if the date number is in our frequency map, if not it is unused so we assign the empty cell collor to it
						if freq.contains_key(&(i as u32)) {
								// self.frequency_to_color() maps a frequency to it's corresponding color, based on the limits specified
								// in the Heatmap structs field
								color = self.frequency_to_color(*freq.get(&(i as u32)).unwrap());
						} else {
								color = self.frequency_to_color(0);
						}
						//  now that we have all the data we can make the square representing that day
						let rect = Rectangle::new()
								.set("fill", color)
								.set("x", xcoord)
								.set("y", ycoord)
								.set("width", format!("{}px", self.size))
								.set("height", format!("{}px", self.size));
						doc = doc.add(rect);
				}
				doc // finally return the document
		}
		/// a simple map which maps numbers to colors based on the limits specified in the [Heatmap] struct
		fn frequency_to_color(&self, frequency : u32) -> &'a str {
				if frequency > self.tier3_limit  {return self.tier4_cell_color;}
				if frequency > self.tier2_limit  {return self.tier3_cell_color;}
				if frequency > self.tier3_limit  {return self.tier2_cell_color;}
				if frequency > self.tier1_limit  {return self.tier1_cell_color;}
				return self.empty_cell_color;
		}

		/// converts dates to a frequency map, note that we map a day to it's number in the year, since this makes it easier
		/// to iterate over the days of a year
		fn dates_to_frequency_map(&self, dates : Vec<NaiveDate>) -> HashMap<u32, u32> {
				// map all dates to their day number
				let day_list : Vec<u32> = dates.iter().map(|date| date.ordinal0()).collect();
				let mut result : HashMap<u32, u32> = HashMap::new();
				// iterate over these numbers and build the frequency map
				day_list.iter().for_each(|day|{
						if result.contains_key(day) {
								result.insert(*day, result.get(day).unwrap() + 1);
						} else {
								result.insert(*day, 1);
						}
				});
				result
		}
		// a function which gets the correct date depending on what span we have (this could probably be implemented into the enum itself)
		// TODO implement in enum
		fn get_span(&self) -> u32 {
				match self.span {
						Span::Week => 7,
						Span::Month => Local::now().num_days_in_month() as u32,
						Span::Year => {
								if Local::now().date_naive().leap_year() { return 366; }
								365
						},
						Span::Custom(x) => x,
				}
		}

		// a function which gets the correct date depending on what start date we have (this could probably be implemented into the enum itself)
		// TODO implement in enum
		fn get_start_date(&self) -> u32 {
				match self.start_date {
						StartDate::Today => Local::now().ordinal0(),
						StartDate::StartPeriod => {
								match self.span {
										Span::Week => Local::now().ordinal0() - Local::now().weekday().num_days_from_monday(),
										Span::Month => NaiveDate::from_ymd_opt(Local::now().year(), Local::now().month(), 1).unwrap().ordinal0(),
										Span::Year => 0,
										Span::Custom(_) => Local::now().ordinal0(),
								}
						},
						StartDate::Custom(x) => x.ordinal0()
				}
		}
}