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            "<svg ",
161            format!(r#"<svg preserveAspectRatio="{preserve_aspect_ratio}" "#).as_str(),
162        )
163    } else {
164        svg_buffer
165    }
166}
167
168fn multi_channel_chart(
169    charts_data: Vec<ChannelChartData>,
170    config: &SvgChartConfig,
171    solid_input: bool,
172    start_from: usize,
173    axis_color: RGBColor,
174    area: &DrawingArea<SVGBackend<'_>, plotters::coord::Shift>,
175    sample_rate: f64,
176) {
177    let num_samples = charts_data
178        .iter()
179        .map(|chart| chart.data.len())
180        .max()
181        .unwrap_or_default();
182    let min_val = charts_data
183        .iter()
184        .flat_map(|c| c.data.iter())
185        .cloned()
186        .fold(f32::INFINITY, f32::min);
187    let max_val = charts_data
188        .iter()
189        .flat_map(|c| c.data.iter())
190        .cloned()
191        .fold(f32::NEG_INFINITY, f32::max);
192
193    let range = (max_val - min_val).max(f32::EPSILON);
194    let y_min = (min_val - range * 0.1) as f64;
195    let y_max = (max_val + range * 0.1) as f64;
196
197    // Build chart
198    let mut chart = ChartBuilder::on(area)
199        .margin(5)
200        .x_label_area_size(35)
201        .y_label_area_size(50)
202        .build_cartesian_2d(
203            start_from as f64..(num_samples + start_from) as f64,
204            y_min..y_max,
205        )
206        .unwrap();
207
208    let mut mesh = chart.configure_mesh();
209
210    mesh.axis_style(axis_color.mix(0.3));
211
212    if !config.show_grid {
213        mesh.disable_mesh();
214    } else {
215        mesh.light_line_style(axis_color.mix(0.1))
216            .bold_line_style(axis_color.mix(0.2));
217    }
218
219    if config.show_labels {
220        let x_labels = num_x_labels(num_samples, sample_rate);
221        mesh.x_labels(
222            config
223                .max_labels_x_axis
224                .map(|mx| x_labels.min(mx))
225                .unwrap_or(x_labels),
226        )
227        .y_labels(3)
228        .label_style(("sans-serif", 10, &axis_color));
229    }
230
231    let formatter = |v: &f64| time_formatter(*v as usize, sample_rate);
232    if config.format_x_axis_labels_as_time {
233        mesh.x_label_formatter(&formatter);
234    }
235
236    mesh.draw().unwrap();
237
238    let mut has_legend = false;
239
240    // Draw outputs (or inputs as solid when `solid_input` is true) one by one,
241    // registering a legend entry per series.
242    for entry in charts_data.iter().filter(|d| !d.is_input || solid_input) {
243        let ChannelChartData {
244            data: channel_data,
245            color,
246            label,
247            ..
248        } = entry;
249
250        let line_style = ShapeStyle {
251            color: color.to_rgba(),
252            filled: false,
253            stroke_width: config.line_width as u32,
254        };
255
256        let series = chart
257            .draw_series(std::iter::once(PathElement::new(
258                channel_data
259                    .iter()
260                    .enumerate()
261                    .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
262                    .collect::<Vec<(f64, f64)>>(),
263                line_style,
264            )))
265            .unwrap();
266
267        if let Some(label) = label {
268            series
269                .label(label)
270                .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], entry.color));
271            has_legend = true;
272        }
273    }
274
275    // Dashed inputs when not solid
276    if !solid_input && charts_data.iter().any(|d| d.is_input) {
277        for entry in charts_data.iter().filter(|d| d.is_input) {
278            let ChannelChartData {
279                data: channel_data,
280                color,
281                label,
282                ..
283            } = entry;
284
285            let line_style = ShapeStyle {
286                color: color.to_rgba(),
287                filled: false,
288                stroke_width: config.line_width as u32,
289            };
290
291            let dashed = DashedPathElement::new(
292                channel_data
293                    .iter()
294                    .enumerate()
295                    .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
296                    .collect::<Vec<(f64, f64)>>(),
297                2,
298                3,
299                line_style,
300            );
301
302            let series = chart.draw_series(std::iter::once(dashed)).unwrap();
303
304            if let Some(label) = label {
305                series.label(label).legend(|(x, y)| {
306                    DashedPathElement::new(vec![(x, y), (x + 20, y)], 2, 3, entry.color)
307                });
308                has_legend = true;
309            }
310        }
311    }
312
313    abnormal_smaples_series(&charts_data, &mut chart, y_min, y_max);
314
315    if has_legend {
316        let background = parse_hex_color(&config.background_color);
317        let contrasting = get_contrasting_color(&background);
318
319        chart
320            .configure_series_labels()
321            .border_style(contrasting)
322            .background_style(background)
323            .label_font(TextStyle::from(("sans-serif", 10)).color(&contrasting))
324            .draw()
325            .unwrap();
326    }
327}
328
329fn one_channel_chart(
330    chart_data: ChannelChartData,
331    config: &SvgChartConfig,
332    start_from: usize,
333    area: &DrawingArea<SVGBackend<'_>, plotters::coord::Shift>,
334    sample_rate: f64,
335) {
336    let ChannelChartData {
337        data: channel_data,
338        color,
339        label,
340        ..
341    } = &chart_data;
342
343    let num_samples = channel_data.len();
344
345    // Calculate data range
346    let min_val = channel_data.iter().cloned().fold(f32::INFINITY, f32::min);
347    let max_val = channel_data
348        .iter()
349        .cloned()
350        .fold(f32::NEG_INFINITY, f32::max);
351    let range = (max_val - min_val).max(f32::EPSILON);
352    let y_min = (min_val - range * 0.1) as f64;
353    let y_max = (max_val + range * 0.1) as f64;
354
355    // Build chart
356    let mut chart = ChartBuilder::on(area)
357        .margin(5)
358        .x_label_area_size(if label.is_some() { 35 } else { 0 })
359        .y_label_area_size(if label.is_some() { 50 } else { 0 })
360        .build_cartesian_2d(
361            start_from as f64..(num_samples + start_from) as f64,
362            y_min..y_max,
363        )
364        .unwrap();
365
366    let mut mesh = chart.configure_mesh();
367
368    mesh.axis_style(color.mix(0.3));
369
370    if !config.show_grid {
371        mesh.disable_mesh();
372    } else {
373        mesh.light_line_style(color.mix(0.1))
374            .bold_line_style(color.mix(0.2));
375    }
376
377    if let Some(label) = label {
378        let x_labels = num_x_labels(num_samples, sample_rate);
379        mesh.x_labels(
380            config
381                .max_labels_x_axis
382                .map(|mx| x_labels.min(mx))
383                .unwrap_or(x_labels),
384        )
385        .y_labels(3)
386        .x_desc(label)
387        .label_style(("sans-serif", 10, &color));
388    }
389
390    let formatter = |v: &f64| time_formatter(*v as usize, sample_rate);
391    if config.format_x_axis_labels_as_time {
392        mesh.x_label_formatter(&formatter);
393    }
394
395    mesh.draw().unwrap();
396
397    // Draw waveform
398    let line_style = ShapeStyle {
399        color: color.to_rgba(),
400        filled: false,
401        stroke_width: config.line_width as u32,
402    };
403
404    chart
405        .draw_series(std::iter::once(PathElement::new(
406            channel_data
407                .iter()
408                .enumerate()
409                .map(|(i, &sample)| ((i + start_from) as f64, sample as f64))
410                .collect::<Vec<(f64, f64)>>(),
411            line_style,
412        )))
413        .unwrap();
414
415    abnormal_smaples_series(&[chart_data], &mut chart, y_min, y_max);
416}