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()
68 .name(key)
69 .data(data)
70 .label(element::Label::new()
71 .show(true)
72 .position(element::LabelPosition::Top)
73 )
74 }
75}
76
77impl Series for series::Line {}
78impl MultiSeries for series::Line {
79 fn with_defaults<D: Into<DataPoint>>(key: &str, data: Vec<D>) -> Self {
80 series::Line::new().name(key).data(data).symbol_size(8)
81 }
82}
83
84impl Series for series::Pie {}
85impl SingleSeries for series::Pie {
86 fn with_defaults<D: Into<DataPoint>>(data: Vec<D>) -> Self {
88 series::Pie::new()
89 .data(data)
90 .label(element::Label::new().formatter("{b}\n{c}h"))
91 .radius("70%")
92 }
93}
94
95pub(super) trait ChartExt {
97 fn with_defaults(title: &str) -> Self;
98 fn with_axes(self, x_axis_label: &[String], x_axis_label_rotate: f64) -> Self;
99 fn with_legend(self) -> Self;
100 fn with_toolbox(self, features: Feature) -> Self;
101
102 fn create_bar_chart(
103 series: Vec<series::Bar>,
104 x_axis_label: &[String],
105 x_axis_label_rotate: f64,
106 title: &str,
107 ) -> Self
108 where
109 Self: Sized;
110
111 fn create_line_chart(
112 grouped_time_log: Vec<series::Line>,
113 x_axis_label: &[String],
114 x_axis_label_rotate: f64,
115 title: &str,
116 ) -> Self
117 where
118 Self: Sized;
119
120 fn create_pie_chart(series: series::Pie, title: &str) -> Self
121 where
122 Self: Sized;
123
124 fn render_svg(
125 &self,
126 width: u32,
127 height: u32,
128 custom_theme: Option<&str>,
129 ) -> Result<String, ChartSettingError>;
130 fn render_html(
131 &self,
132 width: u64,
133 height: u64,
134 custom_theme: Option<&str>,
135 ) -> Result<String, ChartSettingError>;
136}
137
138impl ChartExt for Chart {
139 fn with_defaults(title: &str) -> Chart {
141 Chart::new()
142 .title(component::Title::new().text(title).left("center"))
143 .tooltip(
144 Tooltip::new()
145 .trigger(element::Trigger::Item)
146 .value_formatter(JsFunction::new_with_args(
147 "value",
148 "return String(value).concat('h');",
149 )),
150 )
151 }
152
153 fn with_axes(self, x_axis_label: &[String], x_axis_label_rotate: f64) -> Self {
155 self.y_axis(
156 component::Axis::new()
157 .type_(AxisType::Value)
158 .name("Hours".to_string())
159 .split_number(10),
160 )
161 .x_axis(
162 component::Axis::new()
163 .type_(AxisType::Category)
164 .data(x_axis_label.to_vec())
165 .axis_label(AxisLabel::new().rotate(x_axis_label_rotate)),
166 )
167 }
168
169 fn with_legend(self) -> Self {
171 self.legend(component::Legend::new().top("7%").width("80%"))
172 }
173
174 fn with_toolbox(self, feature: Feature) -> Self {
176 self.toolbox(
177 Toolbox::new()
178 .orient(Orient::Vertical)
179 .top("center")
180 .feature(feature),
181 )
182 }
183
184 fn create_bar_chart(
186 series: Vec<series::Bar>,
187 x_axis_label: &[String],
188 x_axis_label_rotate: f64,
189 title: &str,
190 ) -> Self {
191 let mut chart = Chart::with_defaults(title)
192 .grid(
193 Grid::new()
194 .bottom("5%")
196 .top("20%")
197 .left("2%")
198 .right("5%")
199 .contain_label(true),
200 )
201 .with_axes(x_axis_label, x_axis_label_rotate)
202 .with_legend()
203 .with_toolbox(
204 Feature::new()
205 .save_as_image(SaveAsImage::new())
206 .magic_type(
207 MagicType::new().type_(vec![MagicTypeType::Stack, MagicTypeType::Line]),
208 )
209 .restore(Restore::new())
210 .data_view(DataView::new()),
211 );
212
213 for bar in series {
214 chart = chart.series(bar);
215 }
216 chart
217 }
218
219 fn create_line_chart(
221 lines: Vec<series::Line>,
222 x_axis_label: &[String],
223 x_axis_label_rotate: f64,
224 title: &str,
225 ) -> Self {
226 let mut chart = Chart::with_defaults(title)
227 .grid(
228 Grid::new()
229 .bottom("5%")
231 .top("20%")
232 .left("2%")
233 .right("5%")
234 .contain_label(true),
235 )
236 .with_axes(x_axis_label, x_axis_label_rotate)
237 .with_legend()
238 .with_toolbox(
239 Feature::new()
240 .save_as_image(SaveAsImage::new())
241 .magic_type(MagicType::new().type_(vec![MagicTypeType::Bar]))
242 .restore(Restore::new())
243 .data_view(DataView::new()),
244 );
245
246 for bar in lines {
247 chart = chart.series(bar);
248 }
249 chart
250 }
251
252 fn create_pie_chart(series: series::Pie, title: &str) -> Self {
254 let mut chart = Chart::with_defaults(title).with_toolbox(
255 Feature::new()
256 .save_as_image(SaveAsImage::new())
257 .data_view(DataView::new()),
258 );
259 chart = chart.series(series);
260 chart
261 }
262
263 fn render_svg(
265 &self,
266 width: u32,
267 height: u32,
268 custom_theme: Option<&str>,
269 ) -> Result<String, ChartSettingError> {
270 Ok(charming::ImageRenderer::new(width, height)
271 .theme(load_theme(custom_theme))
272 .render(self)?)
273 }
274
275 fn render_html(
277 &self,
278 width: u64,
279 height: u64,
280 custom_theme: Option<&str>,
281 ) -> Result<String, ChartSettingError> {
282 let mut html = charming::HtmlRenderer::new("Chart", width, height)
283 .render(self)?;
285
286 html = html.replace("'chart'))", "'chart'), echartsTheme)");
288 let theme_name = match custom_theme {
289 Some(_) => "'custom'",
290 None => "''",
291 };
292
293 html = html.replace(
295 r#"<script type="text/javascript">"#,
296 &format!(
297 "<script type=\"text/javascript\">
298 const echartsTheme = typeof setColorTheme === 'function' ? setColorTheme({theme_name}) : {theme_name};"
299 ),
300 );
301
302 let Some(theme) = custom_theme else {
304 return Ok(html);
305 };
306
307 let loader = include_str!("charming_theme_loader.js").replace("JSON", theme);
309 html = html.replace(r"'custom';", &format!("'custom';\n{loader}"));
310
311 Ok(html)
312 }
313}
314
315fn load_theme(custom_theme: Option<&str>) -> Theme {
317 custom_theme.map_or(Theme::Default, |theme| {
318 let theme_loader = include_str!("charming_theme_loader.js").replace("JSON", theme);
319 Theme::Custom("custom", Box::leak(theme_loader.into_boxed_str()))
320 })
321}