1#[derive(Debug, Clone)]
5pub struct DataPoint {
6 pub label: String,
7 pub value: f64,
8}
9
10#[derive(Debug, Clone)]
12pub struct ChartConfig {
13 pub chart_type: ChartType,
14 pub title: Option<String>,
15 pub width: usize,
16 pub height: usize,
17 pub color: String,
18}
19
20#[derive(Debug, Clone)]
22struct ChartLayout {
23 pub total_width: usize,
25 pub total_height: usize,
27 pub chart_width: usize,
29 pub chart_height: usize,
31 pub y_label_width: usize,
33 pub title_height: usize,
35 pub x_label_height: usize,
37}
38
39#[derive(Debug, Clone)]
41pub enum ChartType {
42 Bar,
43 Line,
44 Histogram,
45}
46
47pub fn generate_chart(data: &[DataPoint], config: &ChartConfig) -> String {
49 generate_chart_with_muxbox_title(data, config, None)
50}
51
52pub fn generate_chart_with_muxbox_title(
54 data: &[DataPoint],
55 config: &ChartConfig,
56 muxbox_title: Option<&str>,
57) -> String {
58 if data.is_empty() {
59 return "No chart data".to_string();
60 }
61
62 let layout = calculate_chart_layout(data, config, muxbox_title);
64
65 match config.chart_type {
66 ChartType::Bar => generate_bar_chart(data, config, &layout, muxbox_title),
67 ChartType::Line => generate_line_chart(data, config, &layout, muxbox_title),
68 ChartType::Histogram => generate_histogram(data, config, &layout, muxbox_title),
69 }
70}
71
72fn calculate_chart_layout(
74 data: &[DataPoint],
75 config: &ChartConfig,
76 muxbox_title: Option<&str>,
77) -> ChartLayout {
78 let total_width = config.width.max(20); let total_height = config.height.max(5); let title_height = if let Some(title) = &config.title {
83 let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
84 if should_show_title {
85 2
86 } else {
87 0
88 }
89 } else {
90 0
91 };
92
93 match config.chart_type {
94 ChartType::Bar => {
95 let y_label_width = data.iter().map(|p| p.label.len()).max().unwrap_or(0).max(3); ChartLayout {
99 total_width,
100 total_height,
101 chart_width: total_width.saturating_sub(y_label_width + 4), chart_height: total_height.saturating_sub(title_height),
103 y_label_width,
104 title_height,
105 x_label_height: 0,
106 }
107 }
108 ChartType::Line => {
109 let y_label_width = 6; let x_label_height = 1; ChartLayout {
114 total_width,
115 total_height,
116 chart_width: total_width.saturating_sub(y_label_width + 2),
117 chart_height: total_height.saturating_sub(title_height + x_label_height + 1),
118 y_label_width,
119 title_height,
120 x_label_height,
121 }
122 }
123 ChartType::Histogram => {
124 let x_label_height = 2; ChartLayout {
128 total_width,
129 total_height,
130 chart_width: total_width,
131 chart_height: total_height.saturating_sub(title_height + x_label_height),
132 y_label_width: 0,
133 title_height,
134 x_label_height,
135 }
136 }
137 }
138}
139
140fn generate_bar_chart(
141 data: &[DataPoint],
142 config: &ChartConfig,
143 layout: &ChartLayout,
144 muxbox_title: Option<&str>,
145) -> String {
146 let max_value = data.iter().map(|p| p.value).fold(0.0, f64::max);
147 let mut result = String::new();
148
149 if let Some(title) = &config.title {
151 let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
152 if should_show_title {
153 let title_centered = center_text(title, layout.total_width);
154 result.push_str(&format!("{}\n", title_centered));
155 if layout.title_height > 1 {
156 result.push('\n');
157 }
158 }
159 }
160
161 let bar_width = layout.chart_width.saturating_sub(2); let lines_per_bar = if data.is_empty() {
166 1
167 } else {
168 (layout.chart_height / data.len()).max(1)
169 };
170 let total_lines_needed = data.len() * lines_per_bar;
171
172 for (_i, point) in data.iter().enumerate() {
173 let bar_length = if max_value > 0.0 {
174 ((point.value / max_value) * bar_width as f64).round() as usize
175 } else {
176 0
177 };
178
179 let label = format!("{:>width$}", point.label, width = layout.y_label_width);
181
182 let bar = "█".repeat(bar_length);
184 let padding = " ".repeat(bar_width.saturating_sub(bar_length));
185
186 let value_str = if point.value.fract() == 0.0 {
188 format!("{:.0}", point.value)
189 } else {
190 format!("{:.1}", point.value)
191 };
192
193 result.push_str(&format!("{} │{}{} {}\n", label, bar, padding, value_str));
195
196 for _ in 1..lines_per_bar {
198 let empty_label = " ".repeat(layout.y_label_width);
199 result.push_str(&format!(
200 "{} │{}\n",
201 empty_label,
202 " ".repeat(bar_width + value_str.len() + 1)
203 ));
204 }
205 }
206
207 let lines_used = total_lines_needed;
209 for _ in lines_used..layout.chart_height {
210 result.push_str(&" ".repeat(layout.y_label_width + bar_width + 10));
211 result.push('\n');
212 }
213
214 result.trim_end().to_string() }
216
217fn center_text(text: &str, width: usize) -> String {
219 if text.len() >= width {
220 return text.to_string();
221 }
222
223 let padding = width - text.len();
224 let left_pad = padding / 2;
225 let right_pad = padding - left_pad;
226
227 format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad))
228}
229
230fn generate_line_chart(
231 data: &[DataPoint],
232 config: &ChartConfig,
233 layout: &ChartLayout,
234 muxbox_title: Option<&str>,
235) -> String {
236 if data.len() < 2 {
237 return "Need at least 2 data points for line chart".to_string();
238 }
239
240 let max_value = data.iter().map(|p| p.value).fold(0.0, f64::max);
241 let min_value = data.iter().map(|p| p.value).fold(f64::INFINITY, f64::min);
242 let range = max_value - min_value;
243
244 let mut result = String::new();
245
246 if let Some(title) = &config.title {
248 let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
249 if should_show_title {
250 let title_centered = center_text(title, layout.total_width);
251 result.push_str(&format!("{}\n", title_centered));
252 if layout.title_height > 1 {
253 result.push('\n');
254 }
255 }
256 }
257
258 let mut grid = vec![vec![' '; layout.chart_width]; layout.chart_height];
260
261 for (i, point) in data.iter().enumerate() {
263 let x = if data.len() > 1 {
264 (i as f64 / (data.len() - 1) as f64 * (layout.chart_width - 1) as f64) as usize
265 } else {
266 layout.chart_width / 2
267 };
268
269 let y = if range > 0.0 {
270 layout.chart_height
271 - 1
272 - ((point.value - min_value) / range * (layout.chart_height - 1) as f64) as usize
273 } else {
274 layout.chart_height / 2
275 };
276
277 if x < layout.chart_width && y < layout.chart_height {
278 grid[y][x] = '●';
279 }
280
281 }
283
284 for (row_idx, row) in grid.iter().enumerate() {
286 let y_label = if layout.y_label_width > 0 {
288 let row_from_bottom = (layout.chart_height - 1).saturating_sub(row_idx);
290 let y_value = if range > 0.0 {
291 min_value + (row_from_bottom as f64 / (layout.chart_height - 1) as f64) * range
292 } else {
293 min_value
294 };
295
296 let label_interval = layout.chart_height / 4; if row_idx % label_interval.max(1) == 0 || row_idx == layout.chart_height - 1 {
299 format!("{:>width$.1}", y_value, width = layout.y_label_width)
300 } else {
301 " ".repeat(layout.y_label_width)
302 }
303 } else {
304 String::new()
305 };
306
307 result.push_str(&format!("{} {}\n", y_label, row.iter().collect::<String>()));
308 }
309
310 if layout.x_label_height > 0 && !data.is_empty() {
312 let padding = " ".repeat(layout.y_label_width + 1); result.push_str(&padding);
314
315 let max_labels = layout.chart_width / 6; let step = if data.len() > max_labels {
318 data.len() / max_labels.max(1)
319 } else {
320 1
321 };
322
323 for (i, point) in data.iter().enumerate() {
324 if i % step == 0 || i == data.len() - 1 {
325 let x = if data.len() > 1 {
326 (i as f64 / (data.len() - 1) as f64 * (layout.chart_width - 1) as f64) as usize
327 } else {
328 layout.chart_width / 2
329 };
330
331 let spaces_before = x.saturating_sub(
333 result
334 .lines()
335 .last()
336 .unwrap_or("")
337 .len()
338 .saturating_sub(layout.y_label_width + 1),
339 );
340 if spaces_before < layout.chart_width {
341 result.push_str(&" ".repeat(spaces_before));
342 result.push_str(&point.label.chars().take(4).collect::<String>());
343 }
344 }
345 }
346 result.push('\n');
347 }
348
349 result.trim_end().to_string()
350}
351
352fn generate_histogram(
355 data: &[DataPoint],
356 config: &ChartConfig,
357 layout: &ChartLayout,
358 muxbox_title: Option<&str>,
359) -> String {
360 let max_value = data.iter().map(|p| p.value).fold(0.0, f64::max);
362 let min_value = data.iter().map(|p| p.value).fold(f64::INFINITY, f64::min);
363
364 let max_bins = layout.chart_width / 2; let bins = if data.len() <= max_bins {
367 data.len() } else {
369 max_bins.min(12).max(6) };
371
372 let bin_size = if max_value > min_value {
373 (max_value - min_value) / bins as f64
374 } else {
375 1.0
376 };
377
378 let histogram: Vec<usize> = if data.len() <= bins {
380 data.iter().map(|p| p.value as usize).collect()
382 } else {
383 let mut hist = vec![0; bins];
385 for point in data {
386 let bin_index = if bin_size > 0.0 && max_value > min_value {
387 let normalized = (point.value - min_value) / (max_value - min_value);
388 (normalized * (bins - 1) as f64).round() as usize
389 } else {
390 0
391 };
392 let bin_index = bin_index.min(bins - 1);
393 hist[bin_index] += 1;
394 }
395 hist
396 };
397
398 let max_count = *histogram.iter().max().unwrap_or(&1);
399
400 let mut result = String::new();
401
402 if let Some(title) = &config.title {
404 let should_show_title = muxbox_title.map_or(true, |muxbox_title| muxbox_title != title);
405 if should_show_title {
406 let title_centered = center_text(title, layout.total_width);
407 result.push_str(&format!("{}\n", title_centered));
408 if layout.title_height > 1 {
409 result.push('\n');
410 }
411 }
412 }
413
414 for row in (0..layout.chart_height).rev() {
416 let mut row_chars = 0;
417
418 for (bin_idx, &count) in histogram.iter().enumerate() {
419 let bar_height_needed = if max_count > 0 {
420 (count as f64 / max_count as f64 * layout.chart_height as f64) as usize
421 } else {
422 0
423 };
424
425 if row < bar_height_needed {
426 result.push('█');
427 } else {
428 result.push(' ');
429 }
430 row_chars += 1;
431
432 if bin_idx < bins - 1 {
434 let remaining_bins = bins - bin_idx - 1;
435 let remaining_width = layout.chart_width.saturating_sub(row_chars);
436 let spaces_needed = if remaining_bins > 0 {
437 (remaining_width / remaining_bins).max(1)
438 } else {
439 0
440 };
441
442 for _ in 0..spaces_needed {
443 result.push(' ');
444 row_chars += 1;
445 if row_chars >= layout.chart_width {
446 break;
447 }
448 }
449 }
450
451 if row_chars >= layout.chart_width {
452 break;
453 }
454 }
455
456 while row_chars < layout.chart_width {
458 result.push(' ');
459 row_chars += 1;
460 }
461
462 result.push('\n');
463 }
464
465 if layout.x_label_height > 0 {
467 result.push('\n'); let mut label_line = " ".repeat(layout.chart_width);
471
472 if data.len() <= bins {
473 let min_label_spacing = 4; let max_labels_for_width = layout.chart_width / min_label_spacing;
476 let labels_to_show = data.len().min(max_labels_for_width).min(bins);
477
478 for i in 0..labels_to_show {
479 let data_idx = if labels_to_show == data.len() {
480 i } else {
482 (i * (data.len() - 1)) / (labels_to_show - 1).max(1) };
484
485 let point = &data[data_idx.min(data.len() - 1)];
486 let label = if point.value.fract() == 0.0 {
487 format!("{:.0}", point.value)
488 } else {
489 format!("{:.1}", point.value)
490 };
491
492 let label_position = if labels_to_show > 1 {
494 (i * (layout.chart_width - label.len())) / (labels_to_show - 1)
495 } else {
496 layout.chart_width / 2
497 };
498
499 let start_pos = label_position;
501 let end_pos = start_pos + label.len();
502
503 if end_pos <= layout.chart_width {
504 let line_chars: Vec<char> = label_line.chars().collect();
506 let can_place = (start_pos..end_pos)
507 .all(|pos| pos >= line_chars.len() || line_chars[pos] == ' ');
508
509 if can_place {
510 let label_chars: Vec<char> = label.chars().collect();
512 let mut line_chars = line_chars;
513 line_chars.resize(layout.chart_width, ' ');
514 for (j, &ch) in label_chars.iter().enumerate() {
515 if start_pos + j < line_chars.len() {
516 line_chars[start_pos + j] = ch;
517 }
518 }
519 label_line = line_chars.into_iter().collect();
520 }
521 }
522 }
523 } else {
524 let num_labels = if layout.chart_width > 80 {
526 8
527 } else if layout.chart_width > 60 {
528 6
529 } else if layout.chart_width > 40 {
530 4
531 } else {
532 3
533 };
534
535 for i in 0..num_labels {
536 let bin_idx = if num_labels == 1 {
537 0
538 } else {
539 (i * (bins - 1)) / (num_labels - 1)
540 };
541 let bin_start = min_value + bin_idx as f64 * bin_size;
542 let label = if bin_start.fract() == 0.0 {
543 format!("{:.0}", bin_start)
544 } else {
545 format!("{:.1}", bin_start)
546 };
547
548 let label_position = if bins > 1 {
550 (bin_idx * layout.chart_width) / bins
551 } else {
552 layout.chart_width / 2
553 };
554
555 let start_pos = label_position.saturating_sub(label.len() / 2);
557 let end_pos = start_pos + label.len();
558
559 if end_pos <= layout.chart_width {
560 let label_chars: Vec<char> = label.chars().collect();
562 let mut line_chars: Vec<char> = label_line.chars().collect();
563 for (j, &ch) in label_chars.iter().enumerate() {
564 if start_pos + j < line_chars.len() {
565 line_chars[start_pos + j] = ch;
566 }
567 }
568 label_line = line_chars.into_iter().collect();
569 }
570 }
571 }
572
573 result.push_str(&label_line);
574 }
575
576 result.trim_end().to_string()
577}
578
579pub fn parse_chart_data(content: &str) -> Vec<DataPoint> {
581 let mut data = Vec::new();
582
583 for line in content.lines() {
584 let line = line.trim();
585 if line.is_empty() || line.starts_with('#') {
586 continue;
587 }
588
589 let parts: Vec<&str> = if line.contains(',') {
591 line.split(',').collect()
592 } else if line.contains(':') {
593 line.split(':').collect()
594 } else {
595 line.split_whitespace().collect()
596 };
597
598 if parts.len() >= 2 {
599 let label = parts[0].trim().to_string();
600 if let Ok(value) = parts[1].trim().parse::<f64>() {
601 data.push(DataPoint { label, value });
602 }
603 }
604 }
605
606 data
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn test_parse_chart_data() {
615 let content = "Jan,10\nFeb,20\nMar,15";
616 let data = parse_chart_data(content);
617
618 assert_eq!(data.len(), 3);
619 assert_eq!(data[0].label, "Jan");
620 assert_eq!(data[0].value, 10.0);
621 assert_eq!(data[2].value, 15.0);
622 }
623
624 #[test]
625 fn test_bar_chart_generation() {
626 let data = vec![
627 DataPoint {
628 label: "Item1".to_string(),
629 value: 10.0,
630 },
631 DataPoint {
632 label: "Item2".to_string(),
633 value: 25.0,
634 },
635 ];
636
637 let config = ChartConfig {
638 chart_type: ChartType::Bar,
639 title: Some("Test Chart".to_string()),
640 width: 30,
641 height: 10,
642 color: "blue".to_string(),
643 };
644
645 let result = generate_chart(&data, &config);
646 assert!(result.contains("Test Chart"));
647 assert!(result.contains("Item1"));
648 assert!(result.contains("█"));
649 }
650}