1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
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()
}
}
}