Skip to main content

timechart/
heatmap.rs

1use std::io::Write;
2
3use chrono::{Datelike, NaiveDate};
4
5use crate::{Data, Result};
6
7/// Renders an SVG heatmap.
8pub 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        // No data, no image.
18        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        // The color scale range.
33        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            // Scale
55            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
82/// Projection instances determine where each date goes in the image.
83pub trait Projection {
84    /// Transform a date to x,y coordinates.
85    fn map(&self, date: NaiveDate) -> (u32, u32);
86
87    /// Return the width and height of the image.
88    fn dimensions(&self) -> (u32, u32);
89
90    /// Return the size of each cell in the image.
91    fn size(&self) -> u32;
92
93    /// Return the range of dates in the image.
94    fn range(&self) -> (NaiveDate, NaiveDate);
95}
96
97/// CalendarProjection creates a heatmap that looks like a calendar, with all
98/// of the months in a row.
99pub 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
151/// IsoProjection creates a denser heatmap where each column is a week of the
152/// ISO calendar year and each row is a day of the week (starting with Monday).
153pub 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}