1use 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
13const 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
24pub struct BarGraph {
27 bar_width: usize,
28 colors: ColorMap
29}
30
31impl BarGraph {
32 pub fn new(percents: &Percentages) -> Self {
34 Self { bar_width: 19, colors: ColorMap::new(percents) }
35 }
36
37 fn bar_width(&self) -> usize { self.bar_width }
39
40 fn hour_width(&self) -> usize { self.bar_width + 1 }
42
43 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 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") )?;
75 Ok(offset)
76 }
77
78 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}