1use std::io::Write;
2
3use chrono::{Datelike, NaiveDate};
4
5use crate::{Data, Result};
6
7pub struct Heatmap {
9 pub projection: Box<dyn Projection>,
10 pub colors: Vec<String>,
11 pub unit: Option<String>,
12 pub log_scale: bool,
13}
14
15impl Heatmap {
16 pub fn render<T: Write>(&self, data: &Data, mut io: T) -> Result<()> {
17 if data.is_empty() {
19 writeln!(
20 io,
21 r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>"#
22 )?;
23 return Ok(());
24 }
25
26 let (y0, y1) = data.values().copied().fold((f64::MAX, f64::MIN), |acc, v| {
27 let min = if acc.0 < v { acc.0 } else { v };
28 let max = if acc.1 > v { acc.1 } else { v };
29 (min, max)
30 });
31
32 let y_range = if self.log_scale {
34 y1.log10() - y0.log10()
35 } else {
36 y1 - y0
37 };
38
39 let (width, height) = self.projection.dimensions();
40 write!(
41 io,
42 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">"#
43 )?;
44
45 let (start, end) = self.projection.range();
46 for date in start.iter_days() {
47 if date > end {
48 break;
49 }
50
51 let (x, y) = self.projection.map(date);
52 let value = data.get(&date).copied().unwrap_or(0.0);
53
54 let scaled_value = if self.log_scale {
56 (value.signum() * (1.0 + value.abs()).log10())
57 / (y1.signum() * (1.0 + y1.abs()).log10())
58 } else {
59 (value + y0) / y_range
60 };
61
62 let color = &self.colors[(scaled_value * (self.colors.len() - 1) as f64) as usize];
63
64 writeln!(
65 io,
66 r#"<rect x="{x}" y="{y}" width="{0}" height="{0}" fill="{color}" rx="2" ry="2">"#,
67 self.projection.size()
68 )?;
69 if let Some(ref unit) = self.unit {
70 writeln!(io, "<title>{value} {unit} on {date}</title>")?;
71 } else {
72 writeln!(io, "<title>{value} {date}</title>")?;
73 }
74 writeln!(io, "</rect>")?;
75 }
76 writeln!(io, "</svg>")?;
77
78 Ok(())
79 }
80}
81
82pub trait Projection {
84 fn map(&self, date: NaiveDate) -> (u32, u32);
86
87 fn dimensions(&self) -> (u32, u32);
89
90 fn size(&self) -> u32;
92
93 fn range(&self) -> (NaiveDate, NaiveDate);
95}
96
97pub struct CalendarProjection {
100 start: NaiveDate,
101 end: NaiveDate,
102 size: u32,
103 padding: u32,
104}
105
106impl CalendarProjection {
107 pub fn new(start: NaiveDate, end: NaiveDate, size: u32, padding: u32) -> Self {
108 Self {
109 start,
110 end,
111 size,
112 padding,
113 }
114 }
115}
116
117impl Projection for CalendarProjection {
118 fn map(&self, date: NaiveDate) -> (u32, u32) {
119 let fom = date.with_day(1).unwrap();
120 let year_number = (date.year() - self.start.year()) as u32;
121 let week_number = (fom.weekday().num_days_from_monday() + date.day() - 1) / 7 + 1;
122
123 let x = self.padding
124 + ((date.month() - self.start.month()) % 12)
125 * (self.padding + 7 * (self.size + self.padding))
126 + date.weekday().num_days_from_monday() * (self.size + self.padding);
127
128 let y = self.padding
129 + week_number * (self.size + self.padding)
130 + year_number * 6 * (self.size + self.padding);
131
132 (x, y)
133 }
134
135 fn dimensions(&self) -> (u32, u32) {
136 let years = (self.end.year() - self.start.year() + 1) as u32;
137 let width = self.padding + ((self.size + self.padding) * 8) * 12;
138 let height = self.padding + (self.size + self.padding) * 6 * years;
139 (width, height)
140 }
141
142 fn size(&self) -> u32 {
143 self.size
144 }
145
146 fn range(&self) -> (NaiveDate, NaiveDate) {
147 (self.start, self.end)
148 }
149}
150
151pub struct IsoProjection {
154 start: NaiveDate,
155 end: NaiveDate,
156 size: u32,
157 padding: u32,
158}
159
160impl IsoProjection {
161 pub fn new(start: NaiveDate, end: NaiveDate, size: u32, padding: u32) -> Self {
162 Self {
163 start,
164 end,
165 size,
166 padding,
167 }
168 }
169}
170
171impl Projection for IsoProjection {
172 fn map(&self, date: NaiveDate) -> (u32, u32) {
173 let start = self.start.iso_week();
174 let iso_date = date.iso_week();
175
176 let year_number = (iso_date.year() - start.year()) as u32;
177
178 let x = self.padding + iso_date.week0() * (self.size + self.padding);
179
180 let y = self.padding
181 + date.weekday().num_days_from_monday() * (self.size + self.padding)
182 + year_number * 8 * (self.size + self.padding);
183
184 (x, y)
185 }
186
187 fn dimensions(&self) -> (u32, u32) {
188 let start = self.start.iso_week();
189 let end = self.end.iso_week();
190 let width = self.padding + (self.size + self.padding) * 53;
191 let height =
192 self.padding + (self.size + self.padding) * 8 * (end.year() - start.year() + 1) as u32;
193 (width, height)
194 }
195
196 fn size(&self) -> u32 {
197 self.size
198 }
199
200 fn range(&self) -> (NaiveDate, NaiveDate) {
201 (self.start, self.end)
202 }
203}