insta_fun/
chart.rs

1use plotters::backend::SVGBackend;
2use plotters::drawing::IntoDrawingArea;
3use plotters::element::DashedPathElement;
4use plotters::prelude::*;
5
6use crate::abnormal::{AbnormalSample, abnormal_smaples_series};
7use crate::chart_data::ChannelChartData;
8use crate::config::SnapshotConfig;
9use crate::util::{
10    INPUT_CHANNEL_COLORS, OUTPUT_CHANNEL_COLORS, get_contrasting_color, num_x_labels,
11    parse_hex_color, time_formatter,
12};
13
14/// Chart layout
15///
16/// Whether to plot channels on separate charts or combined charts.
17#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
18pub enum Layout {
19    /// Each channel plots on its own chart
20    #[default]
21    SeparateChannels,
22    /// All input channels plot on one chart, all output channels plot on another chart
23    ///
24    /// Same as `Combined` when `config.with_inputs` is `false`
25    CombinedPerChannelType,
26    /// All channels plot on one chart
27    Combined,
28}
29
30pub(crate) fn generate_svg(
31    input_data: &[Vec<f32>],
32    output_data: &[Vec<f32>],
33    abnormalities: &[Vec<(usize, AbnormalSample)>],
34    config: &SnapshotConfig,
35) -> String {
36    let height_per_channel = config.svg_height_per_channel;
37    let num_channels = output_data.len()
38        + if config.with_inputs {
39            input_data.len()
40        } else {
41            0
42        };
43    let num_samples = output_data.first().map(|c| c.len()).unwrap_or(0);
44
45    if num_samples == 0 || num_channels == 0 {
46        return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><text>Empty</text></svg>".to_string();
47    }
48
49    let svg_width = config.svg_width.unwrap_or(config.num_samples * 2) as u32;
50    let total_height = (height_per_channel * num_channels) as u32;
51
52    // Create SVG backend with buffer
53    let mut svg_buffer = String::new();
54    {
55        let root =
56            SVGBackend::with_string(&mut svg_buffer, (svg_width, total_height)).into_drawing_area();
57
58        // Fill background
59        let bg_color = parse_hex_color(&config.background_color);
60        root.fill(&bg_color).unwrap();
61
62        // Add optional title with contrasting color
63        let current_area = if let Some(ref title) = config.chart_title {
64            let title_color = get_contrasting_color(&bg_color);
65            let text_style = TextStyle::from(("sans-serif", 20)).color(&title_color);
66            root.titled(title, text_style).unwrap()
67        } else {
68            root
69        };
70
71        let input_charts: Vec<ChannelChartData> = if config.with_inputs {
72            input_data
73                .iter()
74                .enumerate()
75                .map(|(i, data)| ChannelChartData::from_input_data(data, i, config))
76                .collect()
77        } else {
78            vec![]
79        };
80
81        let output_charts: Vec<ChannelChartData> = output_data
82            .iter()
83            .zip(abnormalities)
84            .enumerate()
85            .map(|(i, (data, abnormalities))| {
86                ChannelChartData::from_output_data(data, abnormalities, i, config)
87            })
88            .collect();
89
90        let start_sample = config.warm_up.num_samples(config.sample_rate);
91
92        let output_axis_color = parse_hex_color(OUTPUT_CHANNEL_COLORS[0]);
93        let input_axis_color = parse_hex_color(INPUT_CHANNEL_COLORS[0]);
94
95        match config.chart_layout {
96            Layout::SeparateChannels => {
97                // Split area for each channel
98                let areas = current_area.split_evenly((num_channels, 1));
99                for (chart, area) in input_charts
100                    .into_iter()
101                    .chain(output_charts.into_iter())
102                    .zip(areas)
103                {
104                    one_channel_chart(chart, config, start_sample, &area);
105                }
106            }
107            Layout::CombinedPerChannelType => {
108                if config.with_inputs {
109                    let areas = current_area.split_evenly((2, 1));
110
111                    multi_channel_chart(
112                        input_charts,
113                        config,
114                        true,
115                        start_sample,
116                        input_axis_color,
117                        &areas[0],
118                    );
119                    multi_channel_chart(
120                        output_charts,
121                        config,
122                        true,
123                        start_sample,
124                        output_axis_color,
125                        &areas[1],
126                    );
127                } else {
128                    multi_channel_chart(
129                        output_charts,
130                        config,
131                        true,
132                        start_sample,
133                        output_axis_color,
134                        &current_area,
135                    );
136                }
137            }
138            Layout::Combined => {
139                let charts = output_charts.into_iter().chain(input_charts).collect();
140                multi_channel_chart(
141                    charts,
142                    config,
143                    false,
144                    start_sample,
145                    output_axis_color,
146                    &current_area,
147                );
148            }
149        }
150
151        current_area.present().unwrap();
152    }
153
154    svg_buffer
155}
156
157fn multi_channel_chart(
158    charts_data: Vec<ChannelChartData>,
159    config: &SnapshotConfig,
160    solid_input: bool,
161    start_from: usize,
162    axis_color: RGBColor,
163    area: &DrawingArea<SVGBackend<'_>, plotters::coord::Shift>,
164) {
165    let num_samples = charts_data
166        .iter()
167        .map(|chart| chart.data.len())
168        .max()
169        .unwrap_or_default();
170    let min_val = charts_data
171        .iter()
172        .flat_map(|c| c.data.iter())
173        .cloned()
174        .fold(f32::INFINITY, f32::min);
175    let max_val = charts_data
176        .iter()
177        .flat_map(|c| c.data.iter())
178        .cloned()
179        .fold(f32::NEG_INFINITY, f32::max);
180
181    let range = (max_val - min_val).max(f32::EPSILON);
182    let y_min = (min_val - range * 0.1) as f64;
183    let y_max = (max_val + range * 0.1) as f64;
184
185    // Build chart
186    let mut chart = ChartBuilder::on(area)
187        .margin(5)
188        .x_label_area_size(35)
189        .y_label_area_size(50)
190        .build_cartesian_2d(start_from as f64..num_samples as f64, y_min..y_max)
191        .unwrap();
192
193    let mut mesh = chart.configure_mesh();
194
195    mesh.axis_style(axis_color.mix(0.3));
196
197    if !config.show_grid {
198        mesh.disable_mesh();
199    } else {
200        mesh.light_line_style(axis_color.mix(0.1))
201            .bold_line_style(axis_color.mix(0.2));
202    }
203
204    if config.show_labels {
205        let x_labels = num_x_labels(num_samples, config.sample_rate);
206        mesh.x_labels(
207            config
208                .max_labels_x_axis
209                .map(|mx| x_labels.min(mx))
210                .unwrap_or(x_labels),
211        )
212        .y_labels(3)
213        .label_style(("sans-serif", 10, &axis_color));
214    }
215
216    let formatter = |v: &f64| time_formatter(*v as usize, config.sample_rate);
217    if config.format_x_axis_labels_as_time {
218        mesh.x_label_formatter(&formatter);
219    }
220
221    mesh.draw().unwrap();
222
223    let mut has_legend = false;
224
225    let ctx = chart
226        .draw_series(
227            charts_data
228                .iter()
229                .filter(|d| !d.is_input || solid_input)
230                .map(|entry| {
231                    let ChannelChartData {
232                        data: channel_data,
233                        color,
234                        ..
235                    } = entry;
236
237                    let line_style = ShapeStyle {
238                        color: color.to_rgba(),
239                        filled: false,
240                        stroke_width: config.line_width as u32,
241                    };
242
243                    PathElement::new(
244                        channel_data
245                            .iter()
246                            .enumerate()
247                            .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
248                            .collect::<Vec<(f64, f64)>>(),
249                        line_style,
250                    )
251                }),
252        )
253        .unwrap();
254
255    for entry in charts_data
256        .iter()
257        .filter(|d| d.label.is_some() && (!d.is_input || solid_input))
258    {
259        ctx.label(entry.label.as_ref().unwrap())
260            .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], entry.color));
261
262        has_legend = true;
263    }
264
265    if !solid_input && charts_data.iter().any(|d| d.is_input) {
266        let ctx = chart
267            .draw_series(charts_data.iter().filter(|d| d.is_input).map(|entry| {
268                let ChannelChartData {
269                    data: channel_data,
270                    color,
271                    ..
272                } = entry;
273
274                let line_style = ShapeStyle {
275                    color: color.to_rgba(),
276                    filled: false,
277                    stroke_width: config.line_width as u32,
278                };
279
280                DashedPathElement::new(
281                    channel_data
282                        .iter()
283                        .enumerate()
284                        .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
285                        .collect::<Vec<(f64, f64)>>(),
286                    2,
287                    3,
288                    line_style,
289                )
290            }))
291            .unwrap();
292
293        for entry in charts_data
294            .iter()
295            .filter(|d| d.label.is_some() && d.is_input)
296        {
297            ctx.label(entry.label.as_ref().unwrap()).legend(|(x, y)| {
298                DashedPathElement::new(vec![(x, y), (x + 20, y)], 2, 3, entry.color)
299            });
300
301            has_legend = true;
302        }
303    }
304
305    abnormal_smaples_series(&charts_data, &mut chart, y_min, y_max);
306
307    if has_legend {
308        let background = parse_hex_color(&config.background_color);
309        let contrasting = get_contrasting_color(&background);
310
311        chart
312            .configure_series_labels()
313            .border_style(contrasting)
314            .background_style(background)
315            .label_font(TextStyle::from(("sans-serif", 10)).color(&contrasting))
316            .draw()
317            .unwrap();
318    }
319}
320
321fn one_channel_chart(
322    chart_data: ChannelChartData,
323    config: &SnapshotConfig,
324    start_from: usize,
325    area: &DrawingArea<SVGBackend<'_>, plotters::coord::Shift>,
326) {
327    let ChannelChartData {
328        data: channel_data,
329        color,
330        label,
331        ..
332    } = &chart_data;
333
334    let num_samples = channel_data.len();
335
336    // Calculate data range
337    let min_val = channel_data.iter().cloned().fold(f32::INFINITY, f32::min);
338    let max_val = channel_data
339        .iter()
340        .cloned()
341        .fold(f32::NEG_INFINITY, f32::max);
342    let range = (max_val - min_val).max(f32::EPSILON);
343    let y_min = (min_val - range * 0.1) as f64;
344    let y_max = (max_val + range * 0.1) as f64;
345
346    // Build chart
347    let mut chart = ChartBuilder::on(area)
348        .margin(5)
349        .x_label_area_size(if label.is_some() { 35 } else { 0 })
350        .y_label_area_size(if label.is_some() { 50 } else { 0 })
351        .build_cartesian_2d(start_from as f64..num_samples as f64, y_min..y_max)
352        .unwrap();
353
354    let mut mesh = chart.configure_mesh();
355
356    mesh.axis_style(color.mix(0.3));
357
358    if !config.show_grid {
359        mesh.disable_mesh();
360    } else {
361        mesh.light_line_style(color.mix(0.1))
362            .bold_line_style(color.mix(0.2));
363    }
364
365    if let Some(label) = label {
366        let x_labels = num_x_labels(num_samples, config.sample_rate);
367        mesh.x_labels(
368            config
369                .max_labels_x_axis
370                .map(|mx| x_labels.min(mx))
371                .unwrap_or(x_labels),
372        )
373        .y_labels(3)
374        .x_desc(label)
375        .label_style(("sans-serif", 10, &color));
376    }
377
378    let formatter = |v: &f64| time_formatter(*v as usize, config.sample_rate);
379    if config.format_x_axis_labels_as_time {
380        mesh.x_label_formatter(&formatter);
381    }
382
383    mesh.draw().unwrap();
384
385    // Draw waveform
386    let line_style = ShapeStyle {
387        color: color.to_rgba(),
388        filled: false,
389        stroke_width: config.line_width as u32,
390    };
391
392    chart
393        .draw_series(std::iter::once(PathElement::new(
394            channel_data
395                .iter()
396                .enumerate()
397                .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
398                .collect::<Vec<(f64, f64)>>(),
399            line_style,
400        )))
401        .unwrap();
402
403    abnormal_smaples_series(&[chart_data], &mut chart, y_min, y_max);
404}