1use crate::draw_utils::print_with_color_and_background_at;
7use crate::model::common::{Bounds, ScreenBuffer};
8
9#[derive(Debug, Clone)]
11pub struct DataPoint {
12 pub label: String,
13 pub value: f64,
14}
15
16#[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#[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#[derive(Debug, Clone)]
71struct ChartLayout {
72 pub total_width: usize,
74 pub _total_height: usize,
76 pub chart_width: usize,
78 pub chart_height: usize,
80 pub y_label_width: usize,
82 pub title_height: usize,
84 pub x_label_height: usize,
86}
87
88#[derive(Debug, Clone)]
90pub struct ChartComponent {
91 _id: String,
93 config: ChartConfig,
95 data: Vec<DataPoint>,
97}
98
99impl ChartComponent {
100 pub fn new(id: String) -> Self {
102 Self {
103 _id: id,
104 config: ChartConfig::default(),
105 data: Vec::new(),
106 }
107 }
108
109 pub fn with_config(id: String, config: ChartConfig) -> Self {
111 Self {
112 _id: id,
113 config,
114 data: Vec::new(),
115 }
116 }
117
118 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 pub fn set_data(&mut self, data: Vec<DataPoint>) {
129 self.data = data;
130 }
131
132 pub fn get_data(&self) -> &[DataPoint] {
134 &self.data
135 }
136
137 pub fn set_config(&mut self, config: ChartConfig) {
139 self.config = config;
140 }
141
142 pub fn get_config(&self) -> &ChartConfig {
144 &self.config
145 }
146
147 pub fn parse_data_from_content(&mut self, content: &str) {
149 self.data = Self::parse_chart_data(content);
150 }
151
152 pub fn generate(&self) -> String {
154 self.generate_with_muxbox_title(None)
155 }
156
157 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 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 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 pub fn render(&self, bounds: &Bounds, buffer: &mut ScreenBuffer) {
188 self.render_with_colors(bounds, &self.config.color, "black", buffer);
189 }
190
191 pub fn render_with_colors(
193 &self,
194 bounds: &Bounds,
195 fg_color: &str,
196 bg_color: &str,
197 buffer: &mut ScreenBuffer,
198 ) {
199 let mut render_config = self.config.clone();
201 render_config.width = bounds.width();
202 render_config.height = bounds.height();
203
204 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 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 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 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 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 fn calculate_chart_layout(
272 &self,
273 config: &ChartConfig,
274 muxbox_title: Option<&str>,
275 ) -> ChartLayout {
276 let total_width = config.width.max(20); let total_height = config.height.max(5); 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 let y_label_width = self
300 .data
301 .iter()
302 .map(|p| p.label.len())
303 .max()
304 .unwrap_or(0)
305 .max(3); ChartLayout {
308 total_width,
309 _total_height: total_height,
310 chart_width: total_width.saturating_sub(y_label_width + 4), 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 let y_label_width = 6; let x_label_height = 1; 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 let x_label_height = 2; 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 let legend_width = 15; 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 let y_label_width = 8; let x_label_height = 2; 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 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 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 let bar_width = layout.chart_width.saturating_sub(2); 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 let label = format!("{:>width$}", point.label, width = layout.y_label_width);
425
426 let bar = "█".repeat(bar_length);
428 let padding = " ".repeat(bar_width.saturating_sub(bar_length));
429
430 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 result.push_str(&format!("{} │{}{}{}\n", label, bar, padding, value_str));
443
444 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 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() }
464
465 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 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 let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
503
504 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 for (row_idx, row) in grid.iter().enumerate() {
528 let y_label = if layout.y_label_width > 0 {
530 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 let label_interval = layout.chart_height / 4; 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 if layout.x_label_height > 0 && !self.data.is_empty() {
554 let padding = " ".repeat(layout.y_label_width + 1); result.push_str(&padding);
556
557 let max_labels = layout.chart_width / 6; 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 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 fn generate_histogram(
597 &self,
598 config: &ChartConfig,
599 layout: &ChartLayout,
600 muxbox_title: Option<&str>,
601 ) -> String {
602 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 let max_bins = layout.chart_width / 2; let bins = if self.data.len() <= max_bins {
613 self.data.len() } else {
615 max_bins.clamp(6, 12) };
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 let histogram: Vec<usize> = if self.data.len() <= bins {
626 self.data.iter().map(|p| p.value as usize).collect()
628 } else {
629 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 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 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 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 while row_chars < layout.chart_width {
707 result.push(' ');
708 row_chars += 1;
709 }
710
711 result.push('\n');
712 }
713
714 if layout.x_label_height > 0 {
716 result.push('\n'); let mut label_line = " ".repeat(layout.chart_width);
720
721 if self.data.len() <= bins {
722 let min_label_spacing = 4; 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 } else {
731 (i * (self.data.len() - 1)) / (labels_to_show - 1).max(1)
732 };
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 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 let start_pos = label_position;
751 let end_pos = start_pos + label.len();
752
753 if end_pos <= layout.chart_width {
754 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 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 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 let label_position = if bins > 1 {
800 (bin_idx * layout.chart_width) / bins
801 } else {
802 layout.chart_width / 2
803 };
804
805 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 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 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 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 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 let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
865
866 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 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 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); 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 for row in &grid {
898 result.push_str(&row.iter().collect::<String>());
899 result.push('\n');
900 }
901
902 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 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 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 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 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 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 let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
981
982 if config.show_grid {
984 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 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 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 for (row_idx, row) in grid.iter().enumerate() {
1024 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 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 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 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 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 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 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("█")); assert!(result.contains("10")); }
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("●")); }
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("█")); }
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 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 chart.render(&bounds, &mut buffer);
1292
1293 chart.render_with_colors(&bounds, "red", "black", &mut buffer);
1295 }
1296
1297 #[test]
1298 fn test_parse_chart_data_formats() {
1299 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 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 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 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); assert!(layout.y_label_width > 0); }
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 assert!(result
1363 .chars()
1364 .any(|c| matches!(c, '█' | '▓' | '▒' | '░' | '●')));
1365 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 assert!(result.chars().any(|c| matches!(c, '●' | '◆' | '▲' | '■')));
1389 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 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 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); assert_eq!(pie_layout.chart_width, pie_layout.chart_height); 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); assert!(scatter_layout.x_label_height > 0); assert!(scatter_layout.chart_width < scatter_layout.total_width); }
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 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}