Skip to main content

boxmux_lib/components/
chart_component.rs

1//! Chart Component System - Unified chart rendering component with multiple chart types
2//!
3//! This module provides a comprehensive chart rendering component that encapsulates
4//! all chart generation logic while providing a clean, reusable interface.
5
6use crate::draw_utils::print_with_color_and_background_at;
7use crate::model::common::{Bounds, ScreenBuffer};
8
9/// Chart data point
10#[derive(Debug, Clone)]
11pub struct DataPoint {
12    pub label: String,
13    pub value: f64,
14}
15
16/// Supported chart types
17#[derive(Debug, Clone, PartialEq)]
18pub enum ChartType {
19    Bar,
20    Line,
21    Histogram,
22    Pie,
23    Scatter,
24}
25
26impl std::str::FromStr for ChartType {
27    type Err = String;
28
29    fn from_str(s: &str) -> Result<Self, Self::Err> {
30        match s.to_lowercase().as_str() {
31            "bar" => Ok(ChartType::Bar),
32            "line" => Ok(ChartType::Line),
33            "histogram" => Ok(ChartType::Histogram),
34            "pie" => Ok(ChartType::Pie),
35            "scatter" => Ok(ChartType::Scatter),
36            _ => Err(format!("Unknown chart type: {}", s)),
37        }
38    }
39}
40
41/// Chart configuration
42#[derive(Debug, Clone)]
43pub struct ChartConfig {
44    pub chart_type: ChartType,
45    pub title: Option<String>,
46    pub width: usize,
47    pub height: usize,
48    pub color: String,
49    pub show_title: bool,
50    pub show_values: bool,
51    pub show_grid: bool,
52}
53
54impl Default for ChartConfig {
55    fn default() -> Self {
56        Self {
57            chart_type: ChartType::Bar,
58            title: None,
59            width: 40,
60            height: 10,
61            color: "blue".to_string(),
62            show_title: true,
63            show_values: true,
64            show_grid: false,
65        }
66    }
67}
68
69/// Chart layout dimensions and positioning
70#[derive(Debug, Clone)]
71struct ChartLayout {
72    /// Total available width
73    pub total_width: usize,
74    /// Total available height
75    pub _total_height: usize,
76    /// Width for chart content (excluding labels)
77    pub chart_width: usize,
78    /// Height for chart content (excluding title/axes)
79    pub chart_height: usize,
80    /// Width reserved for Y-axis labels
81    pub y_label_width: usize,
82    /// Height reserved for title
83    pub title_height: usize,
84    /// Height reserved for X-axis labels
85    pub x_label_height: usize,
86}
87
88/// Chart rendering component with comprehensive chart type support
89#[derive(Debug, Clone)]
90pub struct ChartComponent {
91    /// Unique identifier for this chart component instance
92    _id: String,
93    /// Chart configuration
94    config: ChartConfig,
95    /// Chart data points
96    data: Vec<DataPoint>,
97}
98
99impl ChartComponent {
100    /// Create a new ChartComponent with default configuration
101    pub fn new(id: String) -> Self {
102        Self {
103            _id: id,
104            config: ChartConfig::default(),
105            data: Vec::new(),
106        }
107    }
108
109    /// Create a ChartComponent with custom configuration
110    pub fn with_config(id: String, config: ChartConfig) -> Self {
111        Self {
112            _id: id,
113            config,
114            data: Vec::new(),
115        }
116    }
117
118    /// Create a ChartComponent with data and configuration
119    pub fn with_data_and_config(id: String, data: Vec<DataPoint>, config: ChartConfig) -> Self {
120        Self {
121            _id: id,
122            config,
123            data,
124        }
125    }
126
127    /// Set chart data
128    pub fn set_data(&mut self, data: Vec<DataPoint>) {
129        self.data = data;
130    }
131
132    /// Get chart data reference
133    pub fn get_data(&self) -> &[DataPoint] {
134        &self.data
135    }
136
137    /// Set chart configuration
138    pub fn set_config(&mut self, config: ChartConfig) {
139        self.config = config;
140    }
141
142    /// Get chart configuration reference
143    pub fn get_config(&self) -> &ChartConfig {
144        &self.config
145    }
146
147    /// Parse chart data from text content
148    pub fn parse_data_from_content(&mut self, content: &str) {
149        self.data = Self::parse_chart_data(content);
150    }
151
152    /// Generate chart content as string (for legacy compatibility)
153    pub fn generate(&self) -> String {
154        self.generate_with_muxbox_title(None)
155    }
156
157    /// Generate chart content with muxbox title context to avoid duplication
158    pub fn generate_with_muxbox_title(&self, muxbox_title: Option<&str>) -> String {
159        if self.data.is_empty() {
160            return "No chart data".to_string();
161        }
162
163        // Update config dimensions if not set
164        let effective_config = ChartConfig {
165            width: self.config.width.max(20),
166            height: self.config.height.max(5),
167            ..self.config.clone()
168        };
169
170        // Calculate smart layout based on chart type and data
171        let layout = self.calculate_chart_layout(&effective_config, muxbox_title);
172
173        match effective_config.chart_type {
174            ChartType::Bar => self.generate_bar_chart(&effective_config, &layout, muxbox_title),
175            ChartType::Line => self.generate_line_chart(&effective_config, &layout, muxbox_title),
176            ChartType::Histogram => {
177                self.generate_histogram(&effective_config, &layout, muxbox_title)
178            }
179            ChartType::Pie => self.generate_pie_chart(&effective_config, &layout, muxbox_title),
180            ChartType::Scatter => {
181                self.generate_scatter_chart(&effective_config, &layout, muxbox_title)
182            }
183        }
184    }
185
186    /// Render chart directly to screen buffer at specified bounds
187    pub fn render(&self, bounds: &Bounds, buffer: &mut ScreenBuffer) {
188        self.render_with_colors(bounds, &self.config.color, "black", buffer);
189    }
190
191    /// Render chart with custom colors
192    pub fn render_with_colors(
193        &self,
194        bounds: &Bounds,
195        fg_color: &str,
196        bg_color: &str,
197        buffer: &mut ScreenBuffer,
198    ) {
199        // Update chart dimensions based on actual bounds
200        let mut render_config = self.config.clone();
201        render_config.width = bounds.width();
202        render_config.height = bounds.height();
203
204        // Generate chart content
205        let chart_content = if self.data.is_empty() {
206            "No chart data".to_string()
207        } else {
208            let layout = self.calculate_chart_layout(&render_config, None);
209            match render_config.chart_type {
210                ChartType::Bar => self.generate_bar_chart(&render_config, &layout, None),
211                ChartType::Line => self.generate_line_chart(&render_config, &layout, None),
212                ChartType::Histogram => self.generate_histogram(&render_config, &layout, None),
213                ChartType::Pie => self.generate_pie_chart(&render_config, &layout, None),
214                ChartType::Scatter => self.generate_scatter_chart(&render_config, &layout, None),
215            }
216        };
217
218        // Render chart content line by line
219        let lines: Vec<&str> = chart_content.lines().collect();
220        for (line_idx, &line) in lines.iter().take(bounds.height()).enumerate() {
221            let y_pos = bounds.top() + line_idx;
222            // Safe UTF-8 truncation to avoid splitting multi-byte characters
223            let display_line = if line.chars().count() > bounds.width() {
224                line.chars().take(bounds.width()).collect::<String>()
225            } else {
226                line.to_string()
227            };
228
229            print_with_color_and_background_at(
230                y_pos,
231                bounds.left(),
232                &Some(fg_color.to_string()),
233                &Some(bg_color.to_string()),
234                &display_line,
235                buffer,
236            );
237        }
238    }
239
240    /// Parse chart data from text content (static version for external use)
241    pub fn parse_chart_data(content: &str) -> Vec<DataPoint> {
242        let mut data = Vec::new();
243
244        for line in content.lines() {
245            let line = line.trim();
246            if line.is_empty() || line.starts_with('#') {
247                continue;
248            }
249
250            // Support formats: "label,value" or "label:value" or "label value"
251            let parts: Vec<&str> = if line.contains(',') {
252                line.split(',').collect()
253            } else if line.contains(':') {
254                line.split(':').collect()
255            } else {
256                line.split_whitespace().collect()
257            };
258
259            if parts.len() >= 2 {
260                let label = parts[0].trim().to_string();
261                if let Ok(value) = parts[1].trim().parse::<f64>() {
262                    data.push(DataPoint { label, value });
263                }
264            }
265        }
266
267        data
268    }
269
270    /// Calculate optimal layout dimensions for chart
271    fn calculate_chart_layout(
272        &self,
273        config: &ChartConfig,
274        muxbox_title: Option<&str>,
275    ) -> ChartLayout {
276        let total_width = config.width.max(20); // Minimum width
277        let total_height = config.height.max(5); // Minimum height
278
279        // Reserve space for title if present and different from muxbox title
280        let title_height = if config.show_title {
281            if let Some(title) = &config.title {
282                let should_show_title =
283                    muxbox_title.is_none_or(|muxbox_title| muxbox_title != title);
284                if should_show_title {
285                    2
286                } else {
287                    0
288                }
289            } else {
290                0
291            }
292        } else {
293            0
294        };
295
296        match config.chart_type {
297            ChartType::Bar => {
298                // Calculate Y-axis label width based on data labels
299                let y_label_width = self
300                    .data
301                    .iter()
302                    .map(|p| p.label.len())
303                    .max()
304                    .unwrap_or(0)
305                    .max(3); // Minimum for values
306
307                ChartLayout {
308                    total_width,
309                    _total_height: total_height,
310                    chart_width: total_width.saturating_sub(y_label_width + 4), // +4 for separator and padding
311                    chart_height: total_height.saturating_sub(title_height),
312                    y_label_width,
313                    title_height,
314                    x_label_height: 0,
315                }
316            }
317            ChartType::Line => {
318                // Line charts need space for Y-axis values and X-axis labels
319                let y_label_width = 6; // Space for numeric values like "100.0"
320                let x_label_height = 1; // Space for X-axis values
321
322                ChartLayout {
323                    total_width,
324                    _total_height: total_height,
325                    chart_width: total_width.saturating_sub(y_label_width + 2),
326                    chart_height: total_height.saturating_sub(title_height + x_label_height + 1),
327                    y_label_width,
328                    title_height,
329                    x_label_height,
330                }
331            }
332            ChartType::Histogram => {
333                // Histograms need space for X-axis labels at bottom
334                let x_label_height = 2; // Space for bin labels
335
336                ChartLayout {
337                    total_width,
338                    _total_height: total_height,
339                    chart_width: total_width,
340                    chart_height: total_height.saturating_sub(title_height + x_label_height),
341                    y_label_width: 0,
342                    title_height,
343                    x_label_height,
344                }
345            }
346            ChartType::Pie => {
347                // Pie charts need square space and legend area
348                let legend_width = 15; // Space for legend on the right
349                let chart_size = (total_width.saturating_sub(legend_width))
350                    .min(total_height.saturating_sub(title_height));
351
352                ChartLayout {
353                    total_width,
354                    _total_height: total_height,
355                    chart_width: chart_size,
356                    chart_height: chart_size,
357                    y_label_width: legend_width,
358                    title_height,
359                    x_label_height: 0,
360                }
361            }
362            ChartType::Scatter => {
363                // Scatter plots need space for X and Y axis labels
364                let y_label_width = 8; // Space for Y-axis numeric values
365                let x_label_height = 2; // Space for X-axis labels
366
367                ChartLayout {
368                    total_width,
369                    _total_height: total_height,
370                    chart_width: total_width.saturating_sub(y_label_width + 2),
371                    chart_height: total_height.saturating_sub(title_height + x_label_height + 1),
372                    y_label_width,
373                    title_height,
374                    x_label_height,
375                }
376            }
377        }
378    }
379
380    /// Generate bar chart
381    fn generate_bar_chart(
382        &self,
383        config: &ChartConfig,
384        layout: &ChartLayout,
385        muxbox_title: Option<&str>,
386    ) -> String {
387        let max_value = self.data.iter().map(|p| p.value).fold(0.0, f64::max);
388        let mut result = String::new();
389
390        // Only add title if it's different from the muxbox title
391        if config.show_title {
392            if let Some(title) = &config.title {
393                let should_show_title =
394                    muxbox_title.is_none_or(|muxbox_title| muxbox_title != title);
395                if should_show_title {
396                    let title_centered = Self::center_text(title, layout.total_width);
397                    result.push_str(&format!("{}\n", title_centered));
398                    if layout.title_height > 1 {
399                        result.push('\n');
400                    }
401                }
402            }
403        }
404
405        // Calculate optimal bar width
406        let bar_width = layout.chart_width.saturating_sub(2); // Reserve space for separator and value
407
408        // Fill available height by distributing bars vertically
409        let lines_per_bar = if self.data.is_empty() {
410            1
411        } else {
412            (layout.chart_height / self.data.len()).max(1)
413        };
414        let total_lines_needed = self.data.len() * lines_per_bar;
415
416        for point in self.data.iter() {
417            let bar_length = if max_value > 0.0 {
418                ((point.value / max_value) * bar_width as f64).round() as usize
419            } else {
420                0
421            };
422
423            // Right-align labels for better alignment
424            let label = format!("{:>width$}", point.label, width = layout.y_label_width);
425
426            // Create bar with proper alignment
427            let bar = "█".repeat(bar_length);
428            let padding = " ".repeat(bar_width.saturating_sub(bar_length));
429
430            // Format value with consistent decimal places
431            let value_str = if config.show_values {
432                if point.value.fract() == 0.0 {
433                    format!(" {:.0}", point.value)
434                } else {
435                    format!(" {:.1}", point.value)
436                }
437            } else {
438                String::new()
439            };
440
441            // Add the main bar line
442            result.push_str(&format!("{} │{}{}{}\n", label, bar, padding, value_str));
443
444            // Add additional lines for this bar to fill vertical space
445            for _ in 1..lines_per_bar {
446                let empty_label = " ".repeat(layout.y_label_width);
447                result.push_str(&format!(
448                    "{} │{}\n",
449                    empty_label,
450                    " ".repeat(bar_width + value_str.len())
451                ));
452            }
453        }
454
455        // Fill remaining vertical space if needed
456        let lines_used = total_lines_needed;
457        for _ in lines_used..layout.chart_height {
458            result.push_str(&" ".repeat(layout.y_label_width + bar_width + 10));
459            result.push('\n');
460        }
461
462        result.trim_end().to_string() // Remove trailing newline
463    }
464
465    /// Generate line chart
466    fn generate_line_chart(
467        &self,
468        config: &ChartConfig,
469        layout: &ChartLayout,
470        muxbox_title: Option<&str>,
471    ) -> String {
472        if self.data.len() < 2 {
473            return "Need at least 2 data points for line chart".to_string();
474        }
475
476        let max_value = self.data.iter().map(|p| p.value).fold(0.0, f64::max);
477        let min_value = self
478            .data
479            .iter()
480            .map(|p| p.value)
481            .fold(f64::INFINITY, f64::min);
482        let range = max_value - min_value;
483
484        let mut result = String::new();
485
486        // Only add title if it's different from the muxbox title
487        if config.show_title {
488            if let Some(title) = &config.title {
489                let should_show_title =
490                    muxbox_title.is_none_or(|muxbox_title| muxbox_title != title);
491                if should_show_title {
492                    let title_centered = Self::center_text(title, layout.total_width);
493                    result.push_str(&format!("{}\n", title_centered));
494                    if layout.title_height > 1 {
495                        result.push('\n');
496                    }
497                }
498            }
499        }
500
501        // Create grid with proper dimensions
502        let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
503
504        // Plot data points
505        for (i, point) in self.data.iter().enumerate() {
506            let x = if self.data.len() > 1 {
507                (i as f64 / (self.data.len() - 1) as f64 * (layout.chart_width - 1) as f64) as usize
508            } else {
509                layout.chart_width / 2
510            };
511
512            let y = if range > 0.0 {
513                layout.chart_height
514                    - 1
515                    - ((point.value - min_value) / range * (layout.chart_height - 1) as f64)
516                        as usize
517            } else {
518                layout.chart_height / 2
519            };
520
521            if x < layout.chart_width && y < layout.chart_height {
522                grid[y][x] = '●';
523            }
524        }
525
526        // Convert grid to string with proper Y-axis labels
527        for (row_idx, row) in grid.iter().enumerate() {
528            // Add Y-axis value labels that correspond to actual data
529            let y_label = if layout.y_label_width > 0 {
530                // Calculate the actual Y value for this row
531                let row_from_bottom = (layout.chart_height - 1).saturating_sub(row_idx);
532                let y_value = if range > 0.0 {
533                    min_value + (row_from_bottom as f64 / (layout.chart_height - 1) as f64) * range
534                } else {
535                    min_value
536                };
537
538                // Only show labels at specific intervals for readability
539                let label_interval = layout.chart_height / 4; // Show ~4 labels
540                if row_idx % label_interval.max(1) == 0 || row_idx == layout.chart_height - 1 {
541                    format!("{:>width$.1}", y_value, width = layout.y_label_width)
542                } else {
543                    " ".repeat(layout.y_label_width)
544                }
545            } else {
546                String::new()
547            };
548
549            result.push_str(&format!("{} {}\n", y_label, row.iter().collect::<String>()));
550        }
551
552        // Add X-axis labels showing actual data point labels
553        if layout.x_label_height > 0 && !self.data.is_empty() {
554            let padding = " ".repeat(layout.y_label_width + 1); // Align with chart
555            result.push_str(&padding);
556
557            // Show labels for each data point, but space them out to avoid crowding
558            let max_labels = layout.chart_width / 6; // Each label needs ~6 chars
559            let step = if self.data.len() > max_labels {
560                self.data.len() / max_labels.max(1)
561            } else {
562                1
563            };
564
565            for (i, point) in self.data.iter().enumerate() {
566                if i % step == 0 || i == self.data.len() - 1 {
567                    let x = if self.data.len() > 1 {
568                        (i as f64 / (self.data.len() - 1) as f64 * (layout.chart_width - 1) as f64)
569                            as usize
570                    } else {
571                        layout.chart_width / 2
572                    };
573
574                    // Position label under the data point
575                    let spaces_before = x.saturating_sub(
576                        result
577                            .lines()
578                            .last()
579                            .unwrap_or("")
580                            .len()
581                            .saturating_sub(layout.y_label_width + 1),
582                    );
583                    if spaces_before < layout.chart_width {
584                        result.push_str(&" ".repeat(spaces_before));
585                        result.push_str(&point.label.chars().take(4).collect::<String>());
586                    }
587                }
588            }
589            result.push('\n');
590        }
591
592        result.trim_end().to_string()
593    }
594
595    /// Generate histogram
596    fn generate_histogram(
597        &self,
598        config: &ChartConfig,
599        layout: &ChartLayout,
600        muxbox_title: Option<&str>,
601    ) -> String {
602        // Create bins based on value ranges
603        let max_value = self.data.iter().map(|p| p.value).fold(0.0, f64::max);
604        let min_value = self
605            .data
606            .iter()
607            .map(|p| p.value)
608            .fold(f64::INFINITY, f64::min);
609
610        // Calculate optimal number of bins based on available width
611        let max_bins = layout.chart_width / 2; // Each bin needs at least 2 chars width
612        let bins = if self.data.len() <= max_bins {
613            self.data.len() // Use one bin per data point if we have space
614        } else {
615            max_bins.clamp(6, 12) // Otherwise use traditional histogram bins
616        };
617
618        let bin_size = if max_value > min_value {
619            (max_value - min_value) / bins as f64
620        } else {
621            1.0
622        };
623
624        // For discrete data points, show each value as its own bar
625        let histogram: Vec<usize> = if self.data.len() <= bins {
626            // Use actual data values directly
627            self.data.iter().map(|p| p.value as usize).collect()
628        } else {
629            // Traditional histogram with value range bins
630            let mut hist = vec![0; bins];
631            for point in &self.data {
632                let bin_index = if bin_size > 0.0 && max_value > min_value {
633                    let normalized = (point.value - min_value) / (max_value - min_value);
634                    (normalized * (bins - 1) as f64).round() as usize
635                } else {
636                    0
637                };
638                let bin_index = bin_index.min(bins - 1);
639                hist[bin_index] += 1;
640            }
641            hist
642        };
643
644        let max_count = *histogram.iter().max().unwrap_or(&1);
645
646        let mut result = String::new();
647
648        // Only add title if it's different from the muxbox title
649        if config.show_title {
650            if let Some(title) = &config.title {
651                let should_show_title =
652                    muxbox_title.is_none_or(|muxbox_title| muxbox_title != title);
653                if should_show_title {
654                    let title_centered = Self::center_text(title, layout.total_width);
655                    result.push_str(&format!("{}\n", title_centered));
656                    if layout.title_height > 1 {
657                        result.push('\n');
658                    }
659                }
660            }
661        }
662
663        // Draw histogram bars from top to bottom, using full width
664        for row in (0..layout.chart_height).rev() {
665            let mut row_chars = 0;
666
667            for (bin_idx, &count) in histogram.iter().enumerate() {
668                let bar_height_needed = if max_count > 0 {
669                    (count as f64 / max_count as f64 * layout.chart_height as f64) as usize
670                } else {
671                    0
672                };
673
674                if row < bar_height_needed {
675                    result.push('█');
676                } else {
677                    result.push(' ');
678                }
679                row_chars += 1;
680
681                // Calculate spacing to distribute bars across full width
682                if bin_idx < bins - 1 {
683                    let remaining_bins = bins - bin_idx - 1;
684                    let remaining_width = layout.chart_width.saturating_sub(row_chars);
685                    let spaces_needed = if remaining_bins > 0 {
686                        (remaining_width / remaining_bins).max(1)
687                    } else {
688                        0
689                    };
690
691                    for _ in 0..spaces_needed {
692                        result.push(' ');
693                        row_chars += 1;
694                        if row_chars >= layout.chart_width {
695                            break;
696                        }
697                    }
698                }
699
700                if row_chars >= layout.chart_width {
701                    break;
702                }
703            }
704
705            // Fill remaining width with spaces
706            while row_chars < layout.chart_width {
707                result.push(' ');
708                row_chars += 1;
709            }
710
711            result.push('\n');
712        }
713
714        // Add X-axis labels if enabled
715        if layout.x_label_height > 0 {
716            result.push('\n'); // Extra line before labels
717
718            // Show more labels when using individual data points as bins
719            let mut label_line = " ".repeat(layout.chart_width);
720
721            if self.data.len() <= bins {
722                // Show labels for actual data points with overlap prevention
723                let min_label_spacing = 4; // Minimum characters between label starts
724                let max_labels_for_width = layout.chart_width / min_label_spacing;
725                let labels_to_show = self.data.len().min(max_labels_for_width).min(bins);
726
727                for i in 0..labels_to_show {
728                    let data_idx = if labels_to_show == self.data.len() {
729                        i // Show all labels
730                    } else {
731                        (i * (self.data.len() - 1)) / (labels_to_show - 1).max(1)
732                        // Sample evenly
733                    };
734
735                    let point = &self.data[data_idx.min(self.data.len() - 1)];
736                    let label = if point.value.fract() == 0.0 {
737                        format!("{:.0}", point.value)
738                    } else {
739                        format!("{:.1}", point.value)
740                    };
741
742                    // Calculate position with better spacing
743                    let label_position = if labels_to_show > 1 {
744                        (i * (layout.chart_width - label.len())) / (labels_to_show - 1)
745                    } else {
746                        layout.chart_width / 2
747                    };
748
749                    // Check for overlap with previous labels
750                    let start_pos = label_position;
751                    let end_pos = start_pos + label.len();
752
753                    if end_pos <= layout.chart_width {
754                        // Check for overlap by ensuring this position doesn't overwrite existing labels
755                        let line_chars: Vec<char> = label_line.chars().collect();
756                        let can_place = (start_pos..end_pos)
757                            .all(|pos| pos >= line_chars.len() || line_chars[pos] == ' ');
758
759                        if can_place {
760                            // Place the label
761                            let label_chars: Vec<char> = label.chars().collect();
762                            let mut line_chars = line_chars;
763                            line_chars.resize(layout.chart_width, ' ');
764                            for (j, &ch) in label_chars.iter().enumerate() {
765                                if start_pos + j < line_chars.len() {
766                                    line_chars[start_pos + j] = ch;
767                                }
768                            }
769                            label_line = line_chars.into_iter().collect();
770                        }
771                    }
772                }
773            } else {
774                // Traditional histogram range labels
775                let num_labels = if layout.chart_width > 80 {
776                    8
777                } else if layout.chart_width > 60 {
778                    6
779                } else if layout.chart_width > 40 {
780                    4
781                } else {
782                    3
783                };
784
785                for i in 0..num_labels {
786                    let bin_idx = if num_labels == 1 {
787                        0
788                    } else {
789                        (i * (bins - 1)) / (num_labels - 1)
790                    };
791                    let bin_start = min_value + bin_idx as f64 * bin_size;
792                    let label = if bin_start.fract() == 0.0 {
793                        format!("{:.0}", bin_start)
794                    } else {
795                        format!("{:.1}", bin_start)
796                    };
797
798                    // Calculate position across the full width
799                    let label_position = if bins > 1 {
800                        (bin_idx * layout.chart_width) / bins
801                    } else {
802                        layout.chart_width / 2
803                    };
804
805                    // Place label if it fits
806                    let start_pos = label_position.saturating_sub(label.len() / 2);
807                    let end_pos = start_pos + label.len();
808
809                    if end_pos <= layout.chart_width {
810                        // Replace spaces with the label characters
811                        let label_chars: Vec<char> = label.chars().collect();
812                        let mut line_chars: Vec<char> = label_line.chars().collect();
813                        for (j, &ch) in label_chars.iter().enumerate() {
814                            if start_pos + j < line_chars.len() {
815                                line_chars[start_pos + j] = ch;
816                            }
817                        }
818                        label_line = line_chars.into_iter().collect();
819                    }
820                }
821            }
822
823            result.push_str(&label_line);
824        }
825
826        result.trim_end().to_string()
827    }
828
829    /// Generate pie chart
830    fn generate_pie_chart(
831        &self,
832        config: &ChartConfig,
833        layout: &ChartLayout,
834        muxbox_title: Option<&str>,
835    ) -> String {
836        let total_value: f64 = self.data.iter().map(|p| p.value).sum();
837        if total_value == 0.0 {
838            return "No data for pie chart".to_string();
839        }
840
841        let mut result = String::new();
842
843        // Only add title if it's different from the muxbox title
844        if config.show_title {
845            if let Some(title) = &config.title {
846                let should_show_title =
847                    muxbox_title.is_none_or(|muxbox_title| muxbox_title != title);
848                if should_show_title {
849                    let title_centered = Self::center_text(title, layout.total_width);
850                    result.push_str(&format!("{}\n", title_centered));
851                    if layout.title_height > 1 {
852                        result.push('\n');
853                    }
854                }
855            }
856        }
857
858        // Calculate pie chart dimensions - make it as circular as possible
859        let radius = (layout.chart_width.min(layout.chart_height) / 2).max(3);
860        let center_x = layout.chart_width / 2;
861        let center_y = layout.chart_height / 2;
862
863        // Create grid for pie chart
864        let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
865
866        // Calculate angles for each slice
867        let mut current_angle = 0.0;
868        let pie_chars = ['█', '▓', '▒', '░', '●', '◐', '◑', '◒'];
869
870        for (slice_idx, point) in self.data.iter().enumerate() {
871            let slice_angle = (point.value / total_value) * 2.0 * std::f64::consts::PI;
872            let slice_char = pie_chars[slice_idx % pie_chars.len()];
873
874            // Fill the slice using simple sector approximation
875            let angle_steps = (slice_angle * radius as f64 / 2.0).ceil() as usize;
876            for step in 0..angle_steps.max(1) {
877                let angle = current_angle + (step as f64 / angle_steps.max(1) as f64) * slice_angle;
878                let _end_angle = current_angle + slice_angle;
879
880                // Draw lines from center to edge for this slice
881                for r in 1..=radius {
882                    let x = center_x as f64 + (r as f64 * angle.cos());
883                    let y = center_y as f64 + (r as f64 * angle.sin() / 2.0); // Adjust for character aspect ratio
884
885                    let grid_x = x.round() as usize;
886                    let grid_y = y.round() as usize;
887
888                    if grid_x < layout.chart_width && grid_y < layout.chart_height {
889                        grid[grid_y][grid_x] = slice_char;
890                    }
891                }
892            }
893            current_angle += slice_angle;
894        }
895
896        // Convert grid to string
897        for row in &grid {
898            result.push_str(&row.iter().collect::<String>());
899            result.push('\n');
900        }
901
902        // Add legend on the right side
903        if layout.y_label_width > 0 {
904            let legend_lines: Vec<String> = result
905                .lines()
906                .collect::<Vec<_>>()
907                .into_iter()
908                .map(|s| s.to_string())
909                .collect();
910            result.clear();
911
912            for (line_idx, line) in legend_lines.iter().enumerate() {
913                result.push_str(line);
914
915                // Add legend entries
916                if line_idx < self.data.len() {
917                    let point = &self.data[line_idx];
918                    let percentage = (point.value / total_value) * 100.0;
919                    let slice_char = pie_chars[line_idx % pie_chars.len()];
920                    let legend_text =
921                        format!(" {} {}: {:.1}%", slice_char, point.label, percentage);
922
923                    // Pad line to fit legend
924                    let padding = layout
925                        .total_width
926                        .saturating_sub(line.len() + legend_text.len());
927                    result.push_str(&" ".repeat(padding));
928                    result.push_str(&legend_text);
929                }
930                result.push('\n');
931            }
932        }
933
934        result.trim_end().to_string()
935    }
936
937    /// Generate scatter chart
938    fn generate_scatter_chart(
939        &self,
940        config: &ChartConfig,
941        layout: &ChartLayout,
942        muxbox_title: Option<&str>,
943    ) -> String {
944        if self.data.len() < 2 {
945            return "Need at least 2 data points for scatter chart".to_string();
946        }
947
948        // For scatter plots, we need X and Y values. We'll use the index as X and value as Y
949        // In a real implementation, DataPoint might have both x and y values
950        let max_y = self.data.iter().map(|p| p.value).fold(0.0, f64::max);
951        let min_y = self
952            .data
953            .iter()
954            .map(|p| p.value)
955            .fold(f64::INFINITY, f64::min);
956        let max_x = self.data.len() as f64;
957        let min_x = 0.0;
958
959        let x_range = max_x - min_x;
960        let y_range = max_y - min_y;
961
962        let mut result = String::new();
963
964        // Only add title if it's different from the muxbox title
965        if config.show_title {
966            if let Some(title) = &config.title {
967                let should_show_title =
968                    muxbox_title.is_none_or(|muxbox_title| muxbox_title != title);
969                if should_show_title {
970                    let title_centered = Self::center_text(title, layout.total_width);
971                    result.push_str(&format!("{}\n", title_centered));
972                    if layout.title_height > 1 {
973                        result.push('\n');
974                    }
975                }
976            }
977        }
978
979        // Create grid for scatter plot
980        let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
981
982        // Draw grid lines if enabled
983        if config.show_grid {
984            // Vertical grid lines
985            for x in (0..layout.chart_width).step_by(layout.chart_width / 5) {
986                for row in grid.iter_mut().take(layout.chart_height) {
987                    row[x] = '│';
988                }
989            }
990            // Horizontal grid lines
991            for y in (0..layout.chart_height).step_by(layout.chart_height / 4) {
992                for x in 0..layout.chart_width {
993                    grid[y][x] = '─';
994                }
995            }
996        }
997
998        // Plot scatter points with different symbols for variety
999        let scatter_symbols = ['●', '◆', '▲', '■', '♦', '✦', '⬢', '⬣'];
1000
1001        for (point_idx, point) in self.data.iter().enumerate() {
1002            let x_pos = if x_range > 0.0 {
1003                ((point_idx as f64 - min_x) / x_range * (layout.chart_width - 1) as f64) as usize
1004            } else {
1005                layout.chart_width / 2
1006            };
1007
1008            let y_pos = if y_range > 0.0 {
1009                layout.chart_height
1010                    - 1
1011                    - ((point.value - min_y) / y_range * (layout.chart_height - 1) as f64) as usize
1012            } else {
1013                layout.chart_height / 2
1014            };
1015
1016            if x_pos < layout.chart_width && y_pos < layout.chart_height {
1017                let symbol = scatter_symbols[point_idx % scatter_symbols.len()];
1018                grid[y_pos][x_pos] = symbol;
1019            }
1020        }
1021
1022        // Convert grid to string with Y-axis labels
1023        for (row_idx, row) in grid.iter().enumerate() {
1024            // Add Y-axis value labels
1025            let y_label = if layout.y_label_width > 0 {
1026                let row_from_bottom = (layout.chart_height - 1).saturating_sub(row_idx);
1027                let y_value = if y_range > 0.0 {
1028                    min_y + (row_from_bottom as f64 / (layout.chart_height - 1) as f64) * y_range
1029                } else {
1030                    min_y
1031                };
1032
1033                // Show labels at intervals
1034                let label_interval = (layout.chart_height / 4).max(1);
1035                if row_idx % label_interval == 0 || row_idx == layout.chart_height - 1 {
1036                    format!("{:>width$.1}", y_value, width = layout.y_label_width)
1037                } else {
1038                    " ".repeat(layout.y_label_width)
1039                }
1040            } else {
1041                String::new()
1042            };
1043
1044            result.push_str(&format!("{} {}\n", y_label, row.iter().collect::<String>()));
1045        }
1046
1047        // Add X-axis labels
1048        if layout.x_label_height > 0 && !self.data.is_empty() {
1049            let padding = " ".repeat(layout.y_label_width + 1);
1050            result.push_str(&padding);
1051
1052            // Show X labels (indices or labels)
1053            let step = if self.data.len() > 10 {
1054                self.data.len() / 8
1055            } else {
1056                1
1057            };
1058            for (i, point) in self.data.iter().enumerate().step_by(step) {
1059                let x_pos = if x_range > 0.0 {
1060                    ((i as f64 - min_x) / x_range * (layout.chart_width - 1) as f64) as usize
1061                } else {
1062                    layout.chart_width / 2
1063                };
1064
1065                // Position label under the point
1066                let spaces_before = x_pos.saturating_sub(
1067                    result
1068                        .lines()
1069                        .last()
1070                        .unwrap_or("")
1071                        .len()
1072                        .saturating_sub(layout.y_label_width + 1),
1073                );
1074                if spaces_before < layout.chart_width {
1075                    result.push_str(&" ".repeat(spaces_before));
1076                    result.push_str(&point.label.chars().take(4).collect::<String>());
1077                }
1078            }
1079            result.push('\n');
1080        }
1081
1082        result.trim_end().to_string()
1083    }
1084
1085    /// Center text within given width
1086    fn center_text(text: &str, width: usize) -> String {
1087        if text.len() >= width {
1088            return text.to_string();
1089        }
1090
1091        let padding = width - text.len();
1092        let left_pad = padding / 2;
1093        let right_pad = padding - left_pad;
1094
1095        format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad))
1096    }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102    // Test helper function
1103    fn create_test_buffer() -> ScreenBuffer {
1104        ScreenBuffer::new()
1105    }
1106
1107    fn create_test_data() -> Vec<DataPoint> {
1108        vec![
1109            DataPoint {
1110                label: "Jan".to_string(),
1111                value: 10.0,
1112            },
1113            DataPoint {
1114                label: "Feb".to_string(),
1115                value: 20.0,
1116            },
1117            DataPoint {
1118                label: "Mar".to_string(),
1119                value: 15.0,
1120            },
1121        ]
1122    }
1123
1124    #[test]
1125    fn test_chart_component_creation() {
1126        let chart = ChartComponent::new("test_chart".to_string());
1127        assert_eq!(chart._id, "test_chart");
1128        assert!(chart.data.is_empty());
1129        assert_eq!(chart.config.chart_type, ChartType::Bar);
1130    }
1131
1132    #[test]
1133    fn test_chart_component_with_config() {
1134        let config = ChartConfig {
1135            chart_type: ChartType::Line,
1136            title: Some("Test Chart".to_string()),
1137            width: 60,
1138            height: 20,
1139            color: "red".to_string(),
1140            show_title: true,
1141            show_values: false,
1142            show_grid: true,
1143        };
1144
1145        let chart = ChartComponent::with_config("test".to_string(), config.clone());
1146        assert_eq!(chart.config.chart_type, ChartType::Line);
1147        assert_eq!(chart.config.width, 60);
1148        assert_eq!(chart.config.height, 20);
1149        assert_eq!(chart.config.color, "red");
1150        assert!(!chart.config.show_values);
1151    }
1152
1153    #[test]
1154    fn test_data_parsing_from_content() {
1155        let mut chart = ChartComponent::new("test".to_string());
1156        let content = "Jan,10\nFeb,20\nMar,15";
1157        chart.parse_data_from_content(content);
1158
1159        assert_eq!(chart.data.len(), 3);
1160        assert_eq!(chart.data[0].label, "Jan");
1161        assert_eq!(chart.data[0].value, 10.0);
1162        assert_eq!(chart.data[2].value, 15.0);
1163    }
1164
1165    #[test]
1166    fn test_bar_chart_generation() {
1167        let data = create_test_data();
1168        let config = ChartConfig {
1169            chart_type: ChartType::Bar,
1170            title: Some("Test Bar Chart".to_string()),
1171            width: 30,
1172            height: 10,
1173            color: "blue".to_string(),
1174            show_title: true,
1175            show_values: true,
1176            show_grid: false,
1177        };
1178
1179        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1180        let result = chart.generate();
1181
1182        assert!(result.contains("Test Bar Chart"));
1183        assert!(result.contains("Jan"));
1184        assert!(result.contains("█")); // Bar characters
1185        assert!(result.contains("10")); // Values should be shown
1186    }
1187
1188    #[test]
1189    fn test_line_chart_generation() {
1190        let data = create_test_data();
1191        let config = ChartConfig {
1192            chart_type: ChartType::Line,
1193            title: Some("Test Line Chart".to_string()),
1194            width: 40,
1195            height: 15,
1196            color: "green".to_string(),
1197            show_title: true,
1198            show_values: true,
1199            show_grid: false,
1200        };
1201
1202        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1203        let result = chart.generate();
1204
1205        assert!(result.contains("Test Line Chart"));
1206        assert!(result.contains("●")); // Data points
1207    }
1208
1209    #[test]
1210    fn test_histogram_generation() {
1211        let data = create_test_data();
1212        let config = ChartConfig {
1213            chart_type: ChartType::Histogram,
1214            title: Some("Test Histogram".to_string()),
1215            width: 50,
1216            height: 12,
1217            color: "yellow".to_string(),
1218            show_title: true,
1219            show_values: false,
1220            show_grid: false,
1221        };
1222
1223        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1224        let result = chart.generate();
1225
1226        assert!(result.contains("Test Histogram"));
1227        assert!(result.contains("█")); // Histogram bars
1228    }
1229
1230    #[test]
1231    fn test_chart_type_from_string() {
1232        assert_eq!("bar".parse::<ChartType>().unwrap(), ChartType::Bar);
1233        assert_eq!("line".parse::<ChartType>().unwrap(), ChartType::Line);
1234        assert_eq!(
1235            "histogram".parse::<ChartType>().unwrap(),
1236            ChartType::Histogram
1237        );
1238        assert_eq!("pie".parse::<ChartType>().unwrap(), ChartType::Pie);
1239        assert_eq!("scatter".parse::<ChartType>().unwrap(), ChartType::Scatter);
1240        assert!("invalid".parse::<ChartType>().is_err());
1241    }
1242
1243    #[test]
1244    fn test_title_suppression_with_muxbox_title() {
1245        let data = create_test_data();
1246        let config = ChartConfig {
1247            chart_type: ChartType::Bar,
1248            title: Some("Same Title".to_string()),
1249            width: 30,
1250            height: 10,
1251            color: "blue".to_string(),
1252            show_title: true,
1253            show_values: true,
1254            show_grid: false,
1255        };
1256
1257        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1258        let result = chart.generate_with_muxbox_title(Some("Same Title"));
1259
1260        // Title should be suppressed when it matches muxbox title
1261        let title_count = result.matches("Same Title").count();
1262        assert_eq!(title_count, 0);
1263    }
1264
1265    #[test]
1266    fn test_empty_data_handling() {
1267        let chart = ChartComponent::new("test".to_string());
1268        let result = chart.generate();
1269        assert_eq!(result, "No chart data");
1270    }
1271
1272    #[test]
1273    fn test_render_to_buffer() {
1274        let data = create_test_data();
1275        let config = ChartConfig {
1276            chart_type: ChartType::Bar,
1277            title: None,
1278            width: 30,
1279            height: 8,
1280            color: "blue".to_string(),
1281            show_title: false,
1282            show_values: true,
1283            show_grid: false,
1284        };
1285
1286        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1287        let bounds = Bounds::new(5, 5, 30, 8);
1288        let mut buffer = create_test_buffer();
1289
1290        // Should not panic and should render without issues
1291        chart.render(&bounds, &mut buffer);
1292
1293        // Test rendering with custom colors
1294        chart.render_with_colors(&bounds, "red", "black", &mut buffer);
1295    }
1296
1297    #[test]
1298    fn test_parse_chart_data_formats() {
1299        // Test comma format
1300        let data1 = ChartComponent::parse_chart_data("A,10\nB,20");
1301        assert_eq!(data1.len(), 2);
1302        assert_eq!(data1[0].label, "A");
1303        assert_eq!(data1[0].value, 10.0);
1304
1305        // Test colon format
1306        let data2 = ChartComponent::parse_chart_data("C:30\nD:40");
1307        assert_eq!(data2.len(), 2);
1308        assert_eq!(data2[0].label, "C");
1309        assert_eq!(data2[0].value, 30.0);
1310
1311        // Test space format
1312        let data3 = ChartComponent::parse_chart_data("E 50\nF 60");
1313        assert_eq!(data3.len(), 2);
1314        assert_eq!(data3[0].label, "E");
1315        assert_eq!(data3[0].value, 50.0);
1316
1317        // Test comments and empty lines
1318        let data4 = ChartComponent::parse_chart_data("# Comment\n\nG,70\n");
1319        assert_eq!(data4.len(), 1);
1320        assert_eq!(data4[0].label, "G");
1321        assert_eq!(data4[0].value, 70.0);
1322    }
1323
1324    #[test]
1325    fn test_chart_layout_calculation() {
1326        let data = create_test_data();
1327        let chart =
1328            ChartComponent::with_data_and_config("test".to_string(), data, ChartConfig::default());
1329
1330        let config = ChartConfig {
1331            chart_type: ChartType::Bar,
1332            width: 50,
1333            height: 20,
1334            ..ChartConfig::default()
1335        };
1336
1337        let layout = chart.calculate_chart_layout(&config, None);
1338        assert_eq!(layout.total_width, 50);
1339        assert!(layout.chart_width < layout.total_width); // Should reserve space for labels
1340        assert!(layout.y_label_width > 0); // Should calculate label width from data
1341    }
1342
1343    #[test]
1344    fn test_pie_chart_generation() {
1345        let data = create_test_data();
1346        let config = ChartConfig {
1347            chart_type: ChartType::Pie,
1348            title: Some("Test Pie Chart".to_string()),
1349            width: 50,
1350            height: 20,
1351            color: "blue".to_string(),
1352            show_title: true,
1353            show_values: true,
1354            show_grid: false,
1355        };
1356
1357        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1358        let result = chart.generate();
1359
1360        assert!(result.contains("Test Pie Chart"));
1361        // Should contain pie chart characters
1362        assert!(result
1363            .chars()
1364            .any(|c| matches!(c, '█' | '▓' | '▒' | '░' | '●')));
1365        // Should contain percentage in legend
1366        assert!(result.contains("%"));
1367    }
1368
1369    #[test]
1370    fn test_scatter_chart_generation() {
1371        let data = create_test_data();
1372        let config = ChartConfig {
1373            chart_type: ChartType::Scatter,
1374            title: Some("Test Scatter Plot".to_string()),
1375            width: 60,
1376            height: 25,
1377            color: "green".to_string(),
1378            show_title: true,
1379            show_values: false,
1380            show_grid: true,
1381        };
1382
1383        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1384        let result = chart.generate();
1385
1386        assert!(result.contains("Test Scatter Plot"));
1387        // Should contain scatter plot symbols
1388        assert!(result.chars().any(|c| matches!(c, '●' | '◆' | '▲' | '■')));
1389        // Should contain grid lines when enabled
1390        assert!(result.contains('│') || result.contains('─'));
1391    }
1392
1393    #[test]
1394    fn test_pie_chart_empty_data() {
1395        let chart = ChartComponent::with_config(
1396            "test".to_string(),
1397            ChartConfig {
1398                chart_type: ChartType::Pie,
1399                ..ChartConfig::default()
1400            },
1401        );
1402        let result = chart.generate();
1403        assert_eq!(result, "No chart data");
1404    }
1405
1406    #[test]
1407    fn test_pie_chart_zero_values() {
1408        let data = vec![
1409            DataPoint {
1410                label: "A".to_string(),
1411                value: 0.0,
1412            },
1413            DataPoint {
1414                label: "B".to_string(),
1415                value: 0.0,
1416            },
1417        ];
1418
1419        let chart = ChartComponent::with_data_and_config(
1420            "test".to_string(),
1421            data,
1422            ChartConfig {
1423                chart_type: ChartType::Pie,
1424                ..ChartConfig::default()
1425            },
1426        );
1427
1428        let result = chart.generate();
1429        assert_eq!(result, "No data for pie chart");
1430    }
1431
1432    #[test]
1433    fn test_scatter_chart_insufficient_data() {
1434        let data = vec![DataPoint {
1435            label: "Single".to_string(),
1436            value: 10.0,
1437        }];
1438
1439        let chart = ChartComponent::with_data_and_config(
1440            "test".to_string(),
1441            data,
1442            ChartConfig {
1443                chart_type: ChartType::Scatter,
1444                ..ChartConfig::default()
1445            },
1446        );
1447
1448        let result = chart.generate();
1449        assert_eq!(result, "Need at least 2 data points for scatter chart");
1450    }
1451
1452    #[test]
1453    fn test_all_chart_types_with_same_data() {
1454        let data = create_test_data();
1455
1456        let chart_types = [
1457            ChartType::Bar,
1458            ChartType::Line,
1459            ChartType::Histogram,
1460            ChartType::Pie,
1461            ChartType::Scatter,
1462        ];
1463
1464        for chart_type in &chart_types {
1465            let config = ChartConfig {
1466                chart_type: chart_type.clone(),
1467                title: Some(format!("{:?} Chart", chart_type)),
1468                width: 40,
1469                height: 15,
1470                color: "blue".to_string(),
1471                show_title: true,
1472                show_values: true,
1473                show_grid: false,
1474            };
1475
1476            let chart =
1477                ChartComponent::with_data_and_config("test".to_string(), data.clone(), config);
1478            let result = chart.generate();
1479
1480            // All chart types should generate some content
1481            assert!(
1482                !result.is_empty(),
1483                "Chart type {:?} generated empty content",
1484                chart_type
1485            );
1486            assert!(
1487                result.contains(&format!("{:?} Chart", chart_type)),
1488                "Chart type {:?} missing title",
1489                chart_type
1490            );
1491        }
1492    }
1493
1494    #[test]
1495    fn test_chart_layout_calculation_new_types() {
1496        let data = create_test_data();
1497        let chart =
1498            ChartComponent::with_data_and_config("test".to_string(), data, ChartConfig::default());
1499
1500        // Test pie chart layout
1501        let pie_config = ChartConfig {
1502            chart_type: ChartType::Pie,
1503            width: 50,
1504            height: 20,
1505            ..ChartConfig::default()
1506        };
1507        let pie_layout = chart.calculate_chart_layout(&pie_config, None);
1508        assert!(pie_layout.y_label_width > 0); // Should have legend space
1509        assert_eq!(pie_layout.chart_width, pie_layout.chart_height); // Should be square-ish
1510
1511        // Test scatter chart layout
1512        let scatter_config = ChartConfig {
1513            chart_type: ChartType::Scatter,
1514            width: 60,
1515            height: 25,
1516            ..ChartConfig::default()
1517        };
1518        let scatter_layout = chart.calculate_chart_layout(&scatter_config, None);
1519        assert!(scatter_layout.y_label_width > 0); // Should have Y-axis labels
1520        assert!(scatter_layout.x_label_height > 0); // Should have X-axis labels
1521        assert!(scatter_layout.chart_width < scatter_layout.total_width); // Should reserve space for labels
1522    }
1523
1524    #[test]
1525    fn test_scatter_chart_with_grid() {
1526        let data = create_test_data();
1527        let config = ChartConfig {
1528            chart_type: ChartType::Scatter,
1529            title: None,
1530            width: 30,
1531            height: 15,
1532            color: "red".to_string(),
1533            show_title: false,
1534            show_values: false,
1535            show_grid: true,
1536        };
1537
1538        let chart = ChartComponent::with_data_and_config("test".to_string(), data, config);
1539        let result = chart.generate();
1540
1541        // Should contain grid characters when show_grid is true
1542        assert!(
1543            result.contains('│') || result.contains('─'),
1544            "Scatter chart with grid should contain grid lines"
1545        );
1546    }
1547
1548    #[test]
1549    fn test_center_text() {
1550        assert_eq!(ChartComponent::center_text("test", 10), "   test   ");
1551        assert_eq!(ChartComponent::center_text("hello", 9), "  hello  ");
1552        assert_eq!(ChartComponent::center_text("toolong", 5), "toolong");
1553    }
1554
1555    #[test]
1556    fn test_data_setters_and_getters() {
1557        let mut chart = ChartComponent::new("test".to_string());
1558        let data = create_test_data();
1559
1560        chart.set_data(data.clone());
1561        assert_eq!(chart.get_data().len(), 3);
1562        assert_eq!(chart.get_data()[0].label, "Jan");
1563
1564        let new_config = ChartConfig {
1565            chart_type: ChartType::Histogram,
1566            ..ChartConfig::default()
1567        };
1568
1569        chart.set_config(new_config.clone());
1570        assert_eq!(chart.get_config().chart_type, ChartType::Histogram);
1571    }
1572}