boxmux_lib/
chart.rs

1// Chart rendering functionality
2
3/// Chart data point
4#[derive(Debug, Clone)]
5pub struct DataPoint {
6    pub label: String,
7    pub value: f64,
8}
9
10/// Chart configuration
11#[derive(Debug, Clone)]
12pub struct ChartConfig {
13    pub chart_type: ChartType,
14    pub title: Option<String>,
15    pub width: usize,
16    pub height: usize,
17    pub color: String,
18}
19
20/// Chart layout dimensions and positioning
21#[derive(Debug, Clone)]
22struct ChartLayout {
23    /// Total available width
24    pub total_width: usize,
25    /// Total available height
26    pub total_height: usize,
27    /// Width for chart content (excluding labels)
28    pub chart_width: usize,
29    /// Height for chart content (excluding title/axes)
30    pub chart_height: usize,
31    /// Width reserved for Y-axis labels
32    pub y_label_width: usize,
33    /// Height reserved for title
34    pub title_height: usize,
35    /// Height reserved for X-axis labels
36    pub x_label_height: usize,
37}
38
39/// Supported chart types
40#[derive(Debug, Clone)]
41pub enum ChartType {
42    Bar,
43    Line,
44    Histogram,
45}
46
47/// Generate ASCII+ chart using Unicode block characters
48pub fn generate_chart(data: &[DataPoint], config: &ChartConfig) -> String {
49    generate_chart_with_muxbox_title(data, config, None)
50}
51
52/// Generate chart with muxbox title context to avoid duplication
53pub fn generate_chart_with_muxbox_title(
54    data: &[DataPoint],
55    config: &ChartConfig,
56    muxbox_title: Option<&str>,
57) -> String {
58    if data.is_empty() {
59        return "No chart data".to_string();
60    }
61
62    // Calculate smart layout based on chart type and data
63    let layout = calculate_chart_layout(data, config, muxbox_title);
64
65    match config.chart_type {
66        ChartType::Bar => generate_bar_chart(data, config, &layout, muxbox_title),
67        ChartType::Line => generate_line_chart(data, config, &layout, muxbox_title),
68        ChartType::Histogram => generate_histogram(data, config, &layout, muxbox_title),
69    }
70}
71
72/// Calculate optimal layout dimensions for chart
73fn calculate_chart_layout(
74    data: &[DataPoint],
75    config: &ChartConfig,
76    muxbox_title: Option<&str>,
77) -> ChartLayout {
78    let total_width = config.width.max(20); // Minimum width
79    let total_height = config.height.max(5); // Minimum height
80
81    // Reserve space for title if present and different from muxbox title
82    let title_height = if let Some(title) = &config.title {
83        let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
84        if should_show_title {
85            2
86        } else {
87            0
88        }
89    } else {
90        0
91    };
92
93    match config.chart_type {
94        ChartType::Bar => {
95            // Calculate Y-axis label width based on data labels
96            let y_label_width = data.iter().map(|p| p.label.len()).max().unwrap_or(0).max(3); // Minimum for values
97
98            ChartLayout {
99                total_width,
100                total_height,
101                chart_width: total_width.saturating_sub(y_label_width + 4), // +4 for separator and padding
102                chart_height: total_height.saturating_sub(title_height),
103                y_label_width,
104                title_height,
105                x_label_height: 0,
106            }
107        }
108        ChartType::Line => {
109            // Line charts need space for Y-axis values and X-axis labels
110            let y_label_width = 6; // Space for numeric values like "100.0"
111            let x_label_height = 1; // Space for X-axis values
112
113            ChartLayout {
114                total_width,
115                total_height,
116                chart_width: total_width.saturating_sub(y_label_width + 2),
117                chart_height: total_height.saturating_sub(title_height + x_label_height + 1),
118                y_label_width,
119                title_height,
120                x_label_height,
121            }
122        }
123        ChartType::Histogram => {
124            // Histograms need space for X-axis labels at bottom
125            let x_label_height = 2; // Space for bin labels
126
127            ChartLayout {
128                total_width,
129                total_height,
130                chart_width: total_width,
131                chart_height: total_height.saturating_sub(title_height + x_label_height),
132                y_label_width: 0,
133                title_height,
134                x_label_height,
135            }
136        }
137    }
138}
139
140fn generate_bar_chart(
141    data: &[DataPoint],
142    config: &ChartConfig,
143    layout: &ChartLayout,
144    muxbox_title: Option<&str>,
145) -> String {
146    let max_value = data.iter().map(|p| p.value).fold(0.0, f64::max);
147    let mut result = String::new();
148
149    // Only add title if it's different from the muxbox title
150    if let Some(title) = &config.title {
151        let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
152        if should_show_title {
153            let title_centered = center_text(title, layout.total_width);
154            result.push_str(&format!("{}\n", title_centered));
155            if layout.title_height > 1 {
156                result.push('\n');
157            }
158        }
159    }
160
161    // Calculate optimal bar width
162    let bar_width = layout.chart_width.saturating_sub(2); // Reserve space for separator and value
163
164    // Fill available height by distributing bars vertically
165    let lines_per_bar = if data.is_empty() {
166        1
167    } else {
168        (layout.chart_height / data.len()).max(1)
169    };
170    let total_lines_needed = data.len() * lines_per_bar;
171
172    for (_i, point) in data.iter().enumerate() {
173        let bar_length = if max_value > 0.0 {
174            ((point.value / max_value) * bar_width as f64).round() as usize
175        } else {
176            0
177        };
178
179        // Right-align labels for better alignment
180        let label = format!("{:>width$}", point.label, width = layout.y_label_width);
181
182        // Create bar with proper alignment
183        let bar = "█".repeat(bar_length);
184        let padding = " ".repeat(bar_width.saturating_sub(bar_length));
185
186        // Format value with consistent decimal places
187        let value_str = if point.value.fract() == 0.0 {
188            format!("{:.0}", point.value)
189        } else {
190            format!("{:.1}", point.value)
191        };
192
193        // Add the main bar line
194        result.push_str(&format!("{} │{}{} {}\n", label, bar, padding, value_str));
195
196        // Add additional lines for this bar to fill vertical space
197        for _ in 1..lines_per_bar {
198            let empty_label = " ".repeat(layout.y_label_width);
199            result.push_str(&format!(
200                "{} │{}\n",
201                empty_label,
202                " ".repeat(bar_width + value_str.len() + 1)
203            ));
204        }
205    }
206
207    // Fill remaining vertical space if needed
208    let lines_used = total_lines_needed;
209    for _ in lines_used..layout.chart_height {
210        result.push_str(&" ".repeat(layout.y_label_width + bar_width + 10));
211        result.push('\n');
212    }
213
214    result.trim_end().to_string() // Remove trailing newline
215}
216
217/// Center text within given width
218fn center_text(text: &str, width: usize) -> String {
219    if text.len() >= width {
220        return text.to_string();
221    }
222
223    let padding = width - text.len();
224    let left_pad = padding / 2;
225    let right_pad = padding - left_pad;
226
227    format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad))
228}
229
230fn generate_line_chart(
231    data: &[DataPoint],
232    config: &ChartConfig,
233    layout: &ChartLayout,
234    muxbox_title: Option<&str>,
235) -> String {
236    if data.len() < 2 {
237        return "Need at least 2 data points for line chart".to_string();
238    }
239
240    let max_value = data.iter().map(|p| p.value).fold(0.0, f64::max);
241    let min_value = data.iter().map(|p| p.value).fold(f64::INFINITY, f64::min);
242    let range = max_value - min_value;
243
244    let mut result = String::new();
245
246    // Only add title if it's different from the muxbox title
247    if let Some(title) = &config.title {
248        let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
249        if should_show_title {
250            let title_centered = center_text(title, layout.total_width);
251            result.push_str(&format!("{}\n", title_centered));
252            if layout.title_height > 1 {
253                result.push('\n');
254            }
255        }
256    }
257
258    // Create grid with proper dimensions
259    let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
260
261    // Plot data points and lines first
262    for (i, point) in data.iter().enumerate() {
263        let x = if data.len() > 1 {
264            (i as f64 / (data.len() - 1) as f64 * (layout.chart_width - 1) as f64) as usize
265        } else {
266            layout.chart_width / 2
267        };
268
269        let y = if range > 0.0 {
270            layout.chart_height
271                - 1
272                - ((point.value - min_value) / range * (layout.chart_height - 1) as f64) as usize
273        } else {
274            layout.chart_height / 2
275        };
276
277        if x < layout.chart_width && y < layout.chart_height {
278            grid[y][x] = '●';
279        }
280
281        // Lines removed for cleaner appearance - data points only
282    }
283
284    // Convert grid to string with proper Y-axis labels
285    for (row_idx, row) in grid.iter().enumerate() {
286        // Add Y-axis value labels that correspond to actual data
287        let y_label = if layout.y_label_width > 0 {
288            // Calculate the actual Y value for this row
289            let row_from_bottom = (layout.chart_height - 1).saturating_sub(row_idx);
290            let y_value = if range > 0.0 {
291                min_value + (row_from_bottom as f64 / (layout.chart_height - 1) as f64) * range
292            } else {
293                min_value
294            };
295
296            // Only show labels at specific intervals for readability
297            let label_interval = layout.chart_height / 4; // Show ~4 labels
298            if row_idx % label_interval.max(1) == 0 || row_idx == layout.chart_height - 1 {
299                format!("{:>width$.1}", y_value, width = layout.y_label_width)
300            } else {
301                " ".repeat(layout.y_label_width)
302            }
303        } else {
304            String::new()
305        };
306
307        result.push_str(&format!("{} {}\n", y_label, row.iter().collect::<String>()));
308    }
309
310    // Add X-axis labels showing actual data point labels
311    if layout.x_label_height > 0 && !data.is_empty() {
312        let padding = " ".repeat(layout.y_label_width + 1); // Align with chart
313        result.push_str(&padding);
314
315        // Show labels for each data point, but space them out to avoid crowding
316        let max_labels = layout.chart_width / 6; // Each label needs ~6 chars
317        let step = if data.len() > max_labels {
318            data.len() / max_labels.max(1)
319        } else {
320            1
321        };
322
323        for (i, point) in data.iter().enumerate() {
324            if i % step == 0 || i == data.len() - 1 {
325                let x = if data.len() > 1 {
326                    (i as f64 / (data.len() - 1) as f64 * (layout.chart_width - 1) as f64) as usize
327                } else {
328                    layout.chart_width / 2
329                };
330
331                // Position label under the data point
332                let spaces_before = x.saturating_sub(
333                    result
334                        .lines()
335                        .last()
336                        .unwrap_or("")
337                        .len()
338                        .saturating_sub(layout.y_label_width + 1),
339                );
340                if spaces_before < layout.chart_width {
341                    result.push_str(&" ".repeat(spaces_before));
342                    result.push_str(&point.label.chars().take(4).collect::<String>());
343                }
344            }
345        }
346        result.push('\n');
347    }
348
349    result.trim_end().to_string()
350}
351
352// Line drawing functions removed - chart now shows data points only for cleaner appearance
353
354fn generate_histogram(
355    data: &[DataPoint],
356    config: &ChartConfig,
357    layout: &ChartLayout,
358    muxbox_title: Option<&str>,
359) -> String {
360    // Create bins based on value ranges
361    let max_value = data.iter().map(|p| p.value).fold(0.0, f64::max);
362    let min_value = data.iter().map(|p| p.value).fold(f64::INFINITY, f64::min);
363
364    // Calculate optimal number of bins based on available width
365    let max_bins = layout.chart_width / 2; // Each bin needs at least 2 chars width
366    let bins = if data.len() <= max_bins {
367        data.len() // Use one bin per data point if we have space
368    } else {
369        max_bins.min(12).max(6) // Otherwise use traditional histogram bins
370    };
371
372    let bin_size = if max_value > min_value {
373        (max_value - min_value) / bins as f64
374    } else {
375        1.0
376    };
377
378    // For discrete data points, show each value as its own bar
379    let histogram: Vec<usize> = if data.len() <= bins {
380        // Use actual data values directly
381        data.iter().map(|p| p.value as usize).collect()
382    } else {
383        // Traditional histogram with value range bins
384        let mut hist = vec![0; bins];
385        for point in data {
386            let bin_index = if bin_size > 0.0 && max_value > min_value {
387                let normalized = (point.value - min_value) / (max_value - min_value);
388                (normalized * (bins - 1) as f64).round() as usize
389            } else {
390                0
391            };
392            let bin_index = bin_index.min(bins - 1);
393            hist[bin_index] += 1;
394        }
395        hist
396    };
397
398    let max_count = *histogram.iter().max().unwrap_or(&1);
399
400    let mut result = String::new();
401
402    // Only add title if it's different from the muxbox title
403    if let Some(title) = &config.title {
404        let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
405        if should_show_title {
406            let title_centered = center_text(title, layout.total_width);
407            result.push_str(&format!("{}\n", title_centered));
408            if layout.title_height > 1 {
409                result.push('\n');
410            }
411        }
412    }
413
414    // Draw histogram bars from top to bottom, using full width
415    for row in (0..layout.chart_height).rev() {
416        let mut row_chars = 0;
417
418        for (bin_idx, &count) in histogram.iter().enumerate() {
419            let bar_height_needed = if max_count > 0 {
420                (count as f64 / max_count as f64 * layout.chart_height as f64) as usize
421            } else {
422                0
423            };
424
425            if row < bar_height_needed {
426                result.push('█');
427            } else {
428                result.push(' ');
429            }
430            row_chars += 1;
431
432            // Calculate spacing to distribute bars across full width
433            if bin_idx < bins - 1 {
434                let remaining_bins = bins - bin_idx - 1;
435                let remaining_width = layout.chart_width.saturating_sub(row_chars);
436                let spaces_needed = if remaining_bins > 0 {
437                    (remaining_width / remaining_bins).max(1)
438                } else {
439                    0
440                };
441
442                for _ in 0..spaces_needed {
443                    result.push(' ');
444                    row_chars += 1;
445                    if row_chars >= layout.chart_width {
446                        break;
447                    }
448                }
449            }
450
451            if row_chars >= layout.chart_width {
452                break;
453            }
454        }
455
456        // Fill remaining width with spaces
457        while row_chars < layout.chart_width {
458            result.push(' ');
459            row_chars += 1;
460        }
461
462        result.push('\n');
463    }
464
465    // Add simplified X-axis labels - show only a few key values to avoid crowding
466    if layout.x_label_height > 0 {
467        result.push('\n'); // Extra line before labels
468
469        // Show more labels when using individual data points as bins
470        let mut label_line = " ".repeat(layout.chart_width);
471
472        if data.len() <= bins {
473            // Show labels for actual data points with overlap prevention
474            let min_label_spacing = 4; // Minimum characters between label starts
475            let max_labels_for_width = layout.chart_width / min_label_spacing;
476            let labels_to_show = data.len().min(max_labels_for_width).min(bins);
477
478            for i in 0..labels_to_show {
479                let data_idx = if labels_to_show == data.len() {
480                    i // Show all labels
481                } else {
482                    (i * (data.len() - 1)) / (labels_to_show - 1).max(1) // Sample evenly
483                };
484
485                let point = &data[data_idx.min(data.len() - 1)];
486                let label = if point.value.fract() == 0.0 {
487                    format!("{:.0}", point.value)
488                } else {
489                    format!("{:.1}", point.value)
490                };
491
492                // Calculate position with better spacing
493                let label_position = if labels_to_show > 1 {
494                    (i * (layout.chart_width - label.len())) / (labels_to_show - 1)
495                } else {
496                    layout.chart_width / 2
497                };
498
499                // Check for overlap with previous labels
500                let start_pos = label_position;
501                let end_pos = start_pos + label.len();
502
503                if end_pos <= layout.chart_width {
504                    // Check for overlap by ensuring this position doesn't overwrite existing labels
505                    let line_chars: Vec<char> = label_line.chars().collect();
506                    let can_place = (start_pos..end_pos)
507                        .all(|pos| pos >= line_chars.len() || line_chars[pos] == ' ');
508
509                    if can_place {
510                        // Place the label
511                        let label_chars: Vec<char> = label.chars().collect();
512                        let mut line_chars = line_chars;
513                        line_chars.resize(layout.chart_width, ' ');
514                        for (j, &ch) in label_chars.iter().enumerate() {
515                            if start_pos + j < line_chars.len() {
516                                line_chars[start_pos + j] = ch;
517                            }
518                        }
519                        label_line = line_chars.into_iter().collect();
520                    }
521                }
522            }
523        } else {
524            // Traditional histogram range labels - show more labels
525            let num_labels = if layout.chart_width > 80 {
526                8
527            } else if layout.chart_width > 60 {
528                6
529            } else if layout.chart_width > 40 {
530                4
531            } else {
532                3
533            };
534
535            for i in 0..num_labels {
536                let bin_idx = if num_labels == 1 {
537                    0
538                } else {
539                    (i * (bins - 1)) / (num_labels - 1)
540                };
541                let bin_start = min_value + bin_idx as f64 * bin_size;
542                let label = if bin_start.fract() == 0.0 {
543                    format!("{:.0}", bin_start)
544                } else {
545                    format!("{:.1}", bin_start)
546                };
547
548                // Calculate position across the full width
549                let label_position = if bins > 1 {
550                    (bin_idx * layout.chart_width) / bins
551                } else {
552                    layout.chart_width / 2
553                };
554
555                // Place label if it fits
556                let start_pos = label_position.saturating_sub(label.len() / 2);
557                let end_pos = start_pos + label.len();
558
559                if end_pos <= layout.chart_width {
560                    // Replace spaces with the label characters
561                    let label_chars: Vec<char> = label.chars().collect();
562                    let mut line_chars: Vec<char> = label_line.chars().collect();
563                    for (j, &ch) in label_chars.iter().enumerate() {
564                        if start_pos + j < line_chars.len() {
565                            line_chars[start_pos + j] = ch;
566                        }
567                    }
568                    label_line = line_chars.into_iter().collect();
569                }
570            }
571        }
572
573        result.push_str(&label_line);
574    }
575
576    result.trim_end().to_string()
577}
578
579/// Parse chart data from text content
580pub fn parse_chart_data(content: &str) -> Vec<DataPoint> {
581    let mut data = Vec::new();
582
583    for line in content.lines() {
584        let line = line.trim();
585        if line.is_empty() || line.starts_with('#') {
586            continue;
587        }
588
589        // Support formats: "label,value" or "label:value" or "label value"
590        let parts: Vec<&str> = if line.contains(',') {
591            line.split(',').collect()
592        } else if line.contains(':') {
593            line.split(':').collect()
594        } else {
595            line.split_whitespace().collect()
596        };
597
598        if parts.len() >= 2 {
599            let label = parts[0].trim().to_string();
600            if let Ok(value) = parts[1].trim().parse::<f64>() {
601                data.push(DataPoint { label, value });
602            }
603        }
604    }
605
606    data
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_parse_chart_data() {
615        let content = "Jan,10\nFeb,20\nMar,15";
616        let data = parse_chart_data(content);
617
618        assert_eq!(data.len(), 3);
619        assert_eq!(data[0].label, "Jan");
620        assert_eq!(data[0].value, 10.0);
621        assert_eq!(data[2].value, 15.0);
622    }
623
624    #[test]
625    fn test_bar_chart_generation() {
626        let data = vec![
627            DataPoint {
628                label: "Item1".to_string(),
629                value: 10.0,
630            },
631            DataPoint {
632                label: "Item2".to_string(),
633                value: 25.0,
634            },
635        ];
636
637        let config = ChartConfig {
638            chart_type: ChartType::Bar,
639            title: Some("Test Chart".to_string()),
640            width: 30,
641            height: 10,
642            color: "blue".to_string(),
643        };
644
645        let result = generate_chart(&data, &config);
646        assert!(result.contains("Test Chart"));
647        assert!(result.contains("Item1"));
648        assert!(result.contains("█"));
649    }
650}