gitlab_time_report/charts/
charming_extensions.rs1#![cfg(not(tarpaulin_include))]
5
6use super::{ChartSettingError, round_to_string};
7use crate::TimeDeltaExt;
8use crate::filters::total_time_spent;
9use crate::model::TimeLog;
10use charming::component::{
11 DataView, Feature, Grid, MagicType, MagicTypeType, Restore, SaveAsImage, Toolbox,
12};
13use charming::datatype::DataPoint;
14use charming::element::{AxisLabel, AxisType, JsFunction, Orient, Tooltip};
15use charming::theme::Theme;
16use charming::{Chart, component, element, series};
17use std::collections::BTreeMap;
18
19pub(super) trait Series {
21 fn create_data_point_mapping<'a, T>(
24 grouped_time_log: BTreeMap<impl Into<Option<&'a T>>, Vec<&'a TimeLog>>,
25 ) -> Vec<(String, String)>
26 where
27 T: std::fmt::Display + 'a,
28 {
29 grouped_time_log
30 .into_iter()
31 .map(|(key, time_logs)| {
32 let total_hours = round_to_string(
33 total_time_spent(time_logs).total_hours(),
34 super::ROUNDING_PRECISION,
35 );
36 let key = Self::option_to_string(key);
37 (total_hours, key)
38 })
39 .collect()
40 }
41
42 fn option_to_string<'a, T>(key: impl Into<Option<T>>) -> String
44 where
45 T: std::fmt::Display + 'a,
46 {
47 match key.into() {
48 Some(k) => k.to_string(),
49 None => "None".to_string(),
50 }
51 }
52}
53pub(super) trait MultiSeries: Series {
55 fn with_defaults<D: Into<DataPoint>>(key: &str, data: Vec<D>) -> Self;
56}
57
58pub(super) trait SingleSeries: Series {
60 fn with_defaults<D: Into<DataPoint>>(data: Vec<D>) -> Self;
61}
62
63impl Series for series::Bar {}
64impl MultiSeries for series::Bar {
65 fn with_defaults<D: Into<DataPoint>>(key: &str, data: Vec<D>) -> Self {
67 series::Bar::new().name(key).data(data).label(
68 element::Label::new()
69 .show(true)
70 .position(element::LabelPosition::Top),
71 )
72 }
73}
74
75impl Series for series::Line {}
76impl MultiSeries for series::Line {
77 fn with_defaults<D: Into<DataPoint>>(key: &str, data: Vec<D>) -> Self {
78 series::Line::new().name(key).data(data).symbol_size(8)
79 }
80}
81
82impl Series for series::Pie {}
83impl SingleSeries for series::Pie {
84 fn with_defaults<D: Into<DataPoint>>(data: Vec<D>) -> Self {
86 series::Pie::new()
87 .data(data)
88 .label(element::Label::new().formatter("{b}\n{c}h"))
89 .radius("70%")
90 }
91}
92
93pub(super) trait ChartExt {
95 fn with_defaults(title: &str) -> Self;
96 fn with_axes(self, x_axis_label: &[String], x_axis_label_rotate: f64) -> Self;
97 fn with_legend(self) -> Self;
98 fn with_toolbox(self, features: Feature) -> Self;
99
100 fn create_bar_chart(
101 series: Vec<series::Bar>,
102 x_axis_label: &[String],
103 x_axis_label_rotate: f64,
104 title: &str,
105 ) -> Self
106 where
107 Self: Sized;
108
109 fn create_line_chart(
110 grouped_time_log: Vec<series::Line>,
111 x_axis_label: &[String],
112 x_axis_label_rotate: f64,
113 title: &str,
114 ) -> Self
115 where
116 Self: Sized;
117
118 fn create_pie_chart(series: series::Pie, title: &str) -> Self
119 where
120 Self: Sized;
121
122 fn render_svg(
123 &self,
124 width: u32,
125 height: u32,
126 custom_theme: Option<&str>,
127 ) -> Result<String, ChartSettingError>;
128 fn render_html(
129 &self,
130 width: u64,
131 height: u64,
132 custom_theme: Option<&str>,
133 ) -> Result<String, ChartSettingError>;
134}
135
136impl ChartExt for Chart {
137 fn with_defaults(title: &str) -> Chart {
139 Chart::new()
140 .title(component::Title::new().text(title).left("center"))
141 .tooltip(
142 Tooltip::new()
143 .trigger(element::Trigger::Item)
144 .value_formatter(JsFunction::new_with_args(
145 "value",
146 "return String(value).concat('h');",
147 )),
148 )
149 }
150
151 fn with_axes(self, x_axis_label: &[String], x_axis_label_rotate: f64) -> Self {
153 self.y_axis(
154 component::Axis::new()
155 .type_(AxisType::Value)
156 .name("Hours".to_string())
157 .split_number(10),
158 )
159 .x_axis(
160 component::Axis::new()
161 .type_(AxisType::Category)
162 .data(x_axis_label.to_vec())
163 .axis_label(AxisLabel::new().rotate(x_axis_label_rotate)),
164 )
165 }
166
167 fn with_legend(self) -> Self {
169 self.legend(component::Legend::new().top("7%").width("80%"))
170 }
171
172 fn with_toolbox(self, feature: Feature) -> Self {
174 self.toolbox(
175 Toolbox::new()
176 .orient(Orient::Vertical)
177 .top("center")
178 .feature(feature),
179 )
180 }
181
182 fn create_bar_chart(
184 series: Vec<series::Bar>,
185 x_axis_label: &[String],
186 x_axis_label_rotate: f64,
187 title: &str,
188 ) -> Self {
189 let mut chart = Chart::with_defaults(title)
190 .grid(
191 Grid::new()
192 .bottom("5%")
194 .top("20%")
195 .left("2%")
196 .right("5%")
197 .contain_label(true),
198 )
199 .with_axes(x_axis_label, x_axis_label_rotate)
200 .with_legend()
201 .with_toolbox(
202 Feature::new()
203 .save_as_image(SaveAsImage::new())
204 .magic_type(
205 MagicType::new().type_(vec![MagicTypeType::Stack, MagicTypeType::Line]),
206 )
207 .restore(Restore::new())
208 .data_view(DataView::new()),
209 );
210
211 for bar in series {
212 chart = chart.series(bar);
213 }
214 chart
215 }
216
217 fn create_line_chart(
219 lines: Vec<series::Line>,
220 x_axis_label: &[String],
221 x_axis_label_rotate: f64,
222 title: &str,
223 ) -> Self {
224 let mut chart = Chart::with_defaults(title)
225 .grid(
226 Grid::new()
227 .bottom("5%")
229 .top("20%")
230 .left("2%")
231 .right("5%")
232 .contain_label(true),
233 )
234 .with_axes(x_axis_label, x_axis_label_rotate)
235 .with_legend()
236 .with_toolbox(
237 Feature::new()
238 .save_as_image(SaveAsImage::new())
239 .magic_type(MagicType::new().type_(vec![MagicTypeType::Bar]))
240 .restore(Restore::new())
241 .data_view(DataView::new()),
242 );
243
244 for bar in lines {
245 chart = chart.series(bar);
246 }
247 chart
248 }
249
250 fn create_pie_chart(series: series::Pie, title: &str) -> Self {
252 let mut chart = Chart::with_defaults(title).with_toolbox(
253 Feature::new()
254 .save_as_image(SaveAsImage::new())
255 .data_view(DataView::new()),
256 );
257 chart = chart.series(series);
258 chart
259 }
260
261 fn render_svg(
263 &self,
264 width: u32,
265 height: u32,
266 custom_theme: Option<&str>,
267 ) -> Result<String, ChartSettingError> {
268 Ok(charming::ImageRenderer::new(width, height)
269 .theme(load_theme(custom_theme))
270 .render(self)?)
271 }
272
273 fn render_html(
275 &self,
276 width: u64,
277 height: u64,
278 custom_theme: Option<&str>,
279 ) -> Result<String, ChartSettingError> {
280 let mut html = charming::HtmlRenderer::new("Chart", width, height)
281 .render(self)?;
283
284 html = html.replace("'chart'))", "'chart'), echartsTheme)");
286 let theme_name = match custom_theme {
287 Some(_) => "'custom'",
288 None => "''",
289 };
290
291 html = html.replace(
293 r#"<script type="text/javascript">"#,
294 &format!(
295 "<script type=\"text/javascript\">
296 const echartsTheme = typeof setColorTheme === 'function' ? setColorTheme({theme_name}) : {theme_name};"
297 ),
298 );
299
300 let Some(theme) = custom_theme else {
302 return Ok(html);
303 };
304
305 let loader = include_str!("charming_theme_loader.js").replace("JSON", theme);
307 html = html.replace(r"'custom';", &format!("'custom';\n{loader}"));
308
309 Ok(html)
310 }
311}
312
313fn load_theme(custom_theme: Option<&str>) -> Theme {
315 custom_theme.map_or(Theme::Default, |theme| {
316 let theme_loader = include_str!("charming_theme_loader.js").replace("JSON", theme);
317 Theme::Custom("custom", Box::leak(theme_loader.into_boxed_str()))
318 })
319}