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::SvgChartConfig;
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: &SvgChartConfig,
35    sample_rate: f64,
36    num_samples: usize,
37    start_sample: usize,
38) -> String {
39    let height_per_channel = config.svg_height_per_channel;
40    let num_channels = output_data.len()
41        + if config.with_inputs {
42            input_data.len()
43        } else {
44            0
45        };
46
47    if num_samples == 0 || num_channels == 0 {
48        return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><text>Empty</text></svg>".to_string();
49    }
50
51    let svg_width = config.svg_width.unwrap_or(num_samples * 2) as u32;
52    let total_height = (height_per_channel * num_channels) as u32;
53
54    // Create SVG backend with buffer
55    let mut svg_buffer = String::new();
56    {
57        let root =
58            SVGBackend::with_string(&mut svg_buffer, (svg_width, total_height)).into_drawing_area();
59
60        // Fill background
61        let bg_color = parse_hex_color(&config.background_color);
62        root.fill(&bg_color).unwrap();
63
64        // Add optional title with contrasting color
65        let current_area = if let Some(ref title) = config.chart_title {
66            let title_color = get_contrasting_color(&bg_color);
67            let text_style = TextStyle::from(("sans-serif", 20)).color(&title_color);
68            root.titled(title, text_style).unwrap()
69        } else {
70            root
71        };
72
73        let input_charts: Vec<ChannelChartData> = if config.with_inputs {
74            input_data
75                .iter()
76                .enumerate()
77                .map(|(i, data)| ChannelChartData::from_input_data(data, i, config))
78                .collect()
79        } else {
80            vec![]
81        };
82
83        let output_charts: Vec<ChannelChartData> = output_data
84            .iter()
85            .zip(abnormalities)
86            .enumerate()
87            .map(|(i, (data, abnormalities))| {
88                ChannelChartData::from_output_data(data, abnormalities, i, config)
89            })
90            .collect();
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, sample_rate);
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                        sample_rate,
119                    );
120                    multi_channel_chart(
121                        output_charts,
122                        config,
123                        true,
124                        start_sample,
125                        output_axis_color,
126                        &areas[1],
127                        sample_rate,
128                    );
129                } else {
130                    multi_channel_chart(
131                        output_charts,
132                        config,
133                        true,
134                        start_sample,
135                        output_axis_color,
136                        &current_area,
137                        sample_rate,
138                    );
139                }
140            }
141            Layout::Combined => {
142                let charts = output_charts.into_iter().chain(input_charts).collect();
143                multi_channel_chart(
144                    charts,
145                    config,
146                    false,
147                    start_sample,
148                    output_axis_color,
149                    &current_area,
150                    sample_rate,
151                );
152            }
153        }
154
155        current_area.present().unwrap();
156    }
157
158    if let Some(preserve_aspect_ratio) = config.preserve_aspect_ratio {
159        svg_buffer.replace(
160            format!(r#"<svg width="{svg_width}" height="{total_height}" "#).as_str(),
161            format!(
162                r#"<svg width="100%" height="100%" preserveAspectRatio="{preserve_aspect_ratio}" "#
163            )
164            .as_str(),
165        )
166    } else {
167        svg_buffer
168    }
169}
170
171fn multi_channel_chart(
172    charts_data: Vec<ChannelChartData>,
173    config: &SvgChartConfig,
174    solid_input: bool,
175    start_from: usize,
176    axis_color: RGBColor,
177    area: &DrawingArea<SVGBackend<'_>, plotters::coord::Shift>,
178    sample_rate: f64,
179) {
180    let num_samples = charts_data
181        .iter()
182        .map(|chart| chart.data.len())
183        .max()
184        .unwrap_or_default();
185    let min_val = charts_data
186        .iter()
187        .flat_map(|c| c.data.iter())
188        .cloned()
189        .fold(f32::INFINITY, f32::min);
190    let max_val = charts_data
191        .iter()
192        .flat_map(|c| c.data.iter())
193        .cloned()
194        .fold(f32::NEG_INFINITY, f32::max);
195
196    let range = (max_val - min_val).max(f32::EPSILON);
197    let y_min = (min_val - range * 0.1) as f64;
198    let y_max = (max_val + range * 0.1) as f64;
199
200    // Build chart
201    let mut chart = ChartBuilder::on(area)
202        .margin(5)
203        .x_label_area_size(35)
204        .y_label_area_size(50)
205        .build_cartesian_2d(
206            start_from as f64..(num_samples + start_from) as f64,
207            y_min..y_max,
208        )
209        .unwrap();
210
211    let mut mesh = chart.configure_mesh();
212
213    mesh.axis_style(axis_color.mix(0.3));
214
215    if !config.show_grid {
216        mesh.disable_mesh();
217    } else {
218        mesh.light_line_style(axis_color.mix(0.1))
219            .bold_line_style(axis_color.mix(0.2));
220    }
221
222    if config.show_labels {
223        let x_labels = num_x_labels(num_samples, sample_rate);
224        mesh.x_labels(
225            config
226                .max_labels_x_axis
227                .map(|mx| x_labels.min(mx))
228                .unwrap_or(x_labels),
229        )
230        .y_labels(3)
231        .label_style(("sans-serif", 10, &axis_color));
232    }
233
234    let formatter = |v: &f64| time_formatter(*v as usize, sample_rate);
235    if config.format_x_axis_labels_as_time {
236        mesh.x_label_formatter(&formatter);
237    }
238
239    mesh.draw().unwrap();
240
241    let mut has_legend = false;
242
243    // Draw outputs (or inputs as solid when `solid_input` is true) one by one,
244    // registering a legend entry per series.
245    for entry in charts_data.iter().filter(|d| !d.is_input || solid_input) {
246        let ChannelChartData {
247            data: channel_data,
248            color,
249            label,
250            ..
251        } = entry;
252
253        let line_style = ShapeStyle {
254            color: color.to_rgba(),
255            filled: false,
256            stroke_width: config.line_width as u32,
257        };
258
259        let series = chart
260            .draw_series(std::iter::once(PathElement::new(
261                channel_data
262                    .iter()
263                    .enumerate()
264                    .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
265                    .collect::<Vec<(f64, f64)>>(),
266                line_style,
267            )))
268            .unwrap();
269
270        if let Some(label) = label {
271            series
272                .label(label)
273                .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], entry.color));
274            has_legend = true;
275        }
276    }
277
278    // Dashed inputs when not solid
279    if !solid_input && charts_data.iter().any(|d| d.is_input) {
280        for entry in charts_data.iter().filter(|d| d.is_input) {
281            let ChannelChartData {
282                data: channel_data,
283                color,
284                label,
285                ..
286            } = entry;
287
288            let line_style = ShapeStyle {
289                color: color.to_rgba(),
290                filled: false,
291                stroke_width: config.line_width as u32,
292            };
293
294            let dashed = DashedPathElement::new(
295                channel_data
296                    .iter()
297                    .enumerate()
298                    .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
299                    .collect::<Vec<(f64, f64)>>(),
300                2,
301                3,
302                line_style,
303            );
304
305            let series = chart.draw_series(std::iter::once(dashed)).unwrap();
306
307            if let Some(label) = label {
308                series.label(label).legend(|(x, y)| {
309                    DashedPathElement::new(vec![(x, y), (x + 20, y)], 2, 3, entry.color)
310                });
311                has_legend = true;
312            }
313        }
314    }
315
316    abnormal_smaples_series(&charts_data, &mut chart, y_min, y_max);
317
318    if has_legend {
319        let background = parse_hex_color(&config.background_color);
320        let contrasting = get_contrasting_color(&background);
321
322        chart
323            .configure_series_labels()
324            .border_style(contrasting)
325            .background_style(background)
326            .label_font(TextStyle::from(("sans-serif", 10)).color(&contrasting))
327            .draw()
328            .unwrap();
329    }
330}
331
332fn one_channel_chart(
333    chart_data: ChannelChartData,
334    config: &SvgChartConfig,
335    start_from: usize,
336    area: &DrawingArea<SVGBackend<'_>, plotters::coord::Shift>,
337    sample_rate: f64,
338) {
339    let ChannelChartData {
340        data: channel_data,
341        color,
342        label,
343        ..
344    } = &chart_data;
345
346    let num_samples = channel_data.len();
347
348    // Calculate data range
349    let min_val = channel_data.iter().cloned().fold(f32::INFINITY, f32::min);
350    let max_val = channel_data
351        .iter()
352        .cloned()
353        .fold(f32::NEG_INFINITY, f32::max);
354    let range = (max_val - min_val).max(f32::EPSILON);
355    let y_min = (min_val - range * 0.1) as f64;
356    let y_max = (max_val + range * 0.1) as f64;
357
358    // Build chart
359    let mut chart = ChartBuilder::on(area)
360        .margin(5)
361        .x_label_area_size(if label.is_some() { 35 } else { 0 })
362        .y_label_area_size(if label.is_some() { 50 } else { 0 })
363        .build_cartesian_2d(
364            start_from as f64..(num_samples + start_from) as f64,
365            y_min..y_max,
366        )
367        .unwrap();
368
369    let mut mesh = chart.configure_mesh();
370
371    mesh.axis_style(color.mix(0.3));
372
373    if !config.show_grid {
374        mesh.disable_mesh();
375    } else {
376        mesh.light_line_style(color.mix(0.1))
377            .bold_line_style(color.mix(0.2));
378    }
379
380    if let Some(label) = label {
381        let x_labels = num_x_labels(num_samples, sample_rate);
382        mesh.x_labels(
383            config
384                .max_labels_x_axis
385                .map(|mx| x_labels.min(mx))
386                .unwrap_or(x_labels),
387        )
388        .y_labels(3)
389        .x_desc(label)
390        .label_style(("sans-serif", 10, &color));
391    }
392
393    let formatter = |v: &f64| time_formatter(*v as usize, sample_rate);
394    if config.format_x_axis_labels_as_time {
395        mesh.x_label_formatter(&formatter);
396    }
397
398    mesh.draw().unwrap();
399
400    // Draw waveform
401    let line_style = ShapeStyle {
402        color: color.to_rgba(),
403        filled: false,
404        stroke_width: config.line_width as u32,
405    };
406
407    chart
408        .draw_series(std::iter::once(PathElement::new(
409            channel_data
410                .iter()
411                .enumerate()
412                .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
413                .collect::<Vec<(f64, f64)>>(),
414            line_style,
415        )))
416        .unwrap();
417
418    abnormal_smaples_series(&[chart_data], &mut chart, y_min, y_max);
419}