timelog/chart/
histogram.rs

1//! Represent the construction of an SVG histogram.
2
3use std::io::Write;
4
5use xml::writer::{EventWriter, XmlEvent};
6
7use crate::chart::colors::ColorMap;
8use crate::chart::day::Hour;
9use crate::chart::{utils, DayHours, Percentages};
10use crate::emit_xml;
11use crate::TaskEvent;
12
13// Styles for the bar graph
14const STYLESHEET: &str = r"
15text {
16    font-size: 12;
17    text-anchor: middle;
18}
19";
20
21const BAR_HEIGHT: f32 = 60.0;
22const CHART_HEIGHT: f32 = BAR_HEIGHT + 20.0;
23
24/// Configuration for drawing an hourly graph showing projects worked during
25/// that hour.
26pub struct BarGraph {
27    bar_width: usize,
28    colors:    ColorMap
29}
30
31impl BarGraph {
32    /// Create a [`BarGraph`] from the supplied percentages.
33    pub fn new(percents: &Percentages) -> Self {
34        Self { bar_width: 19, colors: ColorMap::new(percents) }
35    }
36
37    // Displayed width of a bar
38    fn bar_width(&self) -> usize { self.bar_width }
39
40    // Distance between left edge of adjacent bars
41    fn hour_width(&self) -> usize { self.bar_width + 1 }
42
43    // Draw a single bar for the supplied [`Hour`].
44    fn write_hour<W: Write>(
45        &self, w: &mut EventWriter<W>, begin: usize, hr: &Hour, i: usize
46    ) -> crate::Result<()> {
47        let id = format!("hr{:02}", i + begin);
48        let xform = format!("translate({}, {BAR_HEIGHT})", i * self.hour_width());
49        emit_xml!(w, g, id: &id, transform: &xform => {
50            let mut offset = 0.0;
51            for task in hr.iter() {
52                offset = self.write_task(w, task, offset)?;
53            }
54            emit_xml!(w, text, x: "10", y: "13"; &format!("{}", begin + i))
55        })
56    }
57
58    // Draw the block for a single [`TaskEvent`] on the current hour.
59    //
60    // # Errors
61    //
62    // Could return any formatting error
63    fn write_task<W: Write>(
64        &self, w: &mut EventWriter<W>, task: &TaskEvent, offset: f32
65    ) -> crate::Result<f32> {
66        #![allow(clippy::cast_precision_loss)]
67        let height = task.as_secs() as f32 / BAR_HEIGHT;
68        let offset = offset - height;
69        let ht_str = utils::format_coord(height);
70        let off_str = utils::format_coord(offset);
71        let bar = format!("{}", self.bar_width());
72        emit_xml!(w, rect, x: "0", y: &off_str, height: &ht_str, width: &bar,
73            fill: self.colors.get(&task.project()).unwrap_or("black")  // black will only happen if something goes wrong
74        )?;
75        Ok(offset)
76    }
77
78    /// Write an SVG representation of the [`DayHours`] as a bar graph.
79    ///
80    /// # Errors
81    ///
82    /// Could return any formatting error
83    pub fn write<W: Write>(
84        &self, w: &mut EventWriter<W>, day_hours: &DayHours
85    ) -> crate::Result<()> {
86        let width = format!("{}", day_hours.num_hours() * self.hour_width());
87        let view = format!("0 0 {width} {CHART_HEIGHT}");
88        emit_xml!(w, svg, viewbox: &view, width: &width, height: &format!("{CHART_HEIGHT}") => {
89            emit_xml!(w, style; STYLESHEET)?;
90            let path = format!("M0,{BAR_HEIGHT} h{}", day_hours.num_hours() * self.hour_width() - 1);
91            emit_xml!(w, path, d: &path, stroke: "black")?;
92
93            let begin = day_hours.start();
94            for (i, hr) in day_hours.iter().enumerate() {
95                self.write_hour(w, begin, hr, i)?;
96            }
97            Ok(())
98        })
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::time::Duration;
105
106    use spectral::prelude::*;
107    use xml::EmitterConfig;
108
109    use super::*;
110    use crate::chart::{DayHours, TagPercent};
111    use crate::date::DateTime;
112
113    fn percentages() -> Percentages {
114        [
115            TagPercent::new("david", 40.0).unwrap(),
116            TagPercent::new("connie", 30.0).unwrap(),
117            TagPercent::new("mark", 20.0).unwrap(),
118            TagPercent::new("kirsten", 10.0).unwrap()
119        ]
120        .iter()
121        .cloned()
122        .collect()
123    }
124
125    fn make_task(proj: &str, time: (u32, u32, u32), secs: u64) -> TaskEvent {
126        TaskEvent::new(
127            DateTime::new((2022, 3, 21), time).unwrap(),
128            proj,
129            Duration::from_secs(secs)
130        )
131    }
132
133    fn day() -> DayHours {
134        let mut day_hrs = DayHours::default();
135
136        for ev in [
137            make_task("david", (9, 0, 0), 300),
138            make_task("kirsten", (9, 5, 0), 3600),
139            make_task("mark", (10, 5, 0), 3300),
140            make_task("connie", (11, 0, 0), 1800),
141            make_task("kirsten", (11, 30, 0), 1800),
142            make_task("mark", (13, 0, 0), 3600),
143            make_task("connie", (14, 0, 0), 1800)
144        ]
145        .into_iter()
146        {
147            day_hrs.add(ev);
148        }
149
150        day_hrs
151    }
152
153    #[test]
154    fn test_new() {
155        let percents = percentages();
156
157        let bg = BarGraph::new(&percents);
158
159        assert_that!(bg.hour_width()).is_equal_to(&20);
160        assert_that!(bg.bar_width()).is_equal_to(&19);
161    }
162
163    #[test]
164    fn test_write_histogram() {
165        let percents = percentages();
166        let bg = BarGraph::new(&percents);
167        let day_hrs = day();
168
169        let mut output: Vec<u8> = Vec::new();
170        let mut w = EmitterConfig::new()
171            .perform_indent(true)
172            .write_document_declaration(false)
173            .create_writer(&mut output);
174        assert_that!(bg.write(&mut w, &day_hrs)).is_ok();
175        let actual = String::from_utf8(output).unwrap();
176        let expected = r##"<svg viewbox="0 0 120 80" width="120" height="80">
177  <style>
178text {
179    font-size: 12;
180    text-anchor: middle;
181}
182</style>
183  <path d="M0,60 h119" stroke="black" />
184  <g id="hr09" transform="translate(0, 60)">
185    <rect x="0" y="-5" height="5" width="19" fill="#1f78b4" />
186    <rect x="0" y="-60" height="55" width="19" fill="#b2df8a" />
187    <text x="10" y="13">9</text>
188  </g>
189  <g id="hr10" transform="translate(20, 60)">
190    <rect x="0" y="-5" height="5" width="19" fill="#b2df8a" />
191    <rect x="0" y="-60" height="55" width="19" fill="#33a02c" />
192    <text x="10" y="13">10</text>
193  </g>
194  <g id="hr11" transform="translate(40, 60)">
195    <rect x="0" y="0" height="0" width="19" fill="#33a02c" />
196    <rect x="0" y="-30" height="30" width="19" fill="#a6cee3" />
197    <rect x="0" y="-60" height="30" width="19" fill="#b2df8a" />
198    <text x="10" y="13">11</text>
199  </g>
200  <g id="hr12" transform="translate(60, 60)">
201    <rect x="0" y="0" height="0" width="19" fill="#b2df8a" />
202    <text x="10" y="13">12</text>
203  </g>
204  <g id="hr13" transform="translate(80, 60)">
205    <rect x="0" y="-60" height="60" width="19" fill="#33a02c" />
206    <text x="10" y="13">13</text>
207  </g>
208  <g id="hr14" transform="translate(100, 60)">
209    <rect x="0" y="0" height="0" width="19" fill="#33a02c" />
210    <rect x="0" y="-30" height="30" width="19" fill="#a6cee3" />
211    <text x="10" y="13">14</text>
212  </g>
213</svg>"##;
214        assert_that!(actual.as_str()).is_equal_to(expected);
215    }
216}