gitlab_time_report/charts/
charming_extensions.rs

1//! Contains traits and helper functions for creating charts with [`charming`].
2
3// Exclude file from testing, as we would be testing the library implementation.
4#![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
19/// Trait for all charming series
20pub(super) trait Series {
21    /// Turns a grouped time log map into entries of total hours per key and the key itself.
22    /// Can then be converted to [`DataPoint`] for chart data.
23    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    /// Converts an optional key to a string. If the key is `None`, the string "None" is returned.
43    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}
53/// Trait for chart types that require one series for each data point.
54pub(super) trait MultiSeries: Series {
55    fn with_defaults<D: Into<DataPoint>>(key: &str, data: Vec<D>) -> Self;
56}
57
58/// Trait for chart types that require one series for all data points.
59pub(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    /// Creates a new [`series::Bar`] with the given key and data.
66    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    /// Creates a new [`series::Pie`] with the given data.
87    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
95/// Extension methods for [`charming::Chart`]
96pub(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    /// Creates a new chart with default settings
140    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    /// Adds x and y-axis to the chart.
154    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    /// Adds a legend to the chart.
170    fn with_legend(self) -> Self {
171        self.legend(component::Legend::new().top("7%").width("80%"))
172    }
173
174    /// Adds a toolbox with the specified features to the chart.
175    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    /// Creates a new bar chart with the given series, x-axis label, x-axis rotation value and title.
185    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                    // More space for the legend
195                    .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    /// Creates a new line chart with the given series, x-axis label, x-axis rotation value and title.
220    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                    // More space for the legend
230                    .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    /// Creates a new pie chart with the given series and the title.
253    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    /// Renders the chart into an SVG with the theme. If `custom_theme` is `None`, [`Theme::Default`] is used.
264    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    /// Renders the chart into an HTTP with the theme. If `custom_theme` is `None`, [`Theme::Default`] is used.
276    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            //.theme(load_theme(custom_theme)) // Broken in charming 0.6.0
284            .render(self)?;
285
286        // Workaround to inject theme into HTML due to upstream bug.
287        html = html.replace("'chart'))", "'chart'), echartsTheme)");
288        let theme_name = match custom_theme {
289            Some(_) => "'custom'",
290            None => "''",
291        };
292
293        // Inject setColorTheme() from the dashboard and add a fallback for viewing the individual HTML chart files
294        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        // If no theme is specified, return here
303        let Some(theme) = custom_theme else {
304            return Ok(html);
305        };
306
307        // Inject the loader with the theme file contents into the HTML
308        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
315/// Creates a custom theme with the JSON in `custom_theme`.
316fn 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}