1use std::fmt;
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum ChartError {
35 EmptyData,
36 InvalidRange,
37 InvalidDimensions,
38}
39
40impl fmt::Display for ChartError {
41 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
42 match self {
43 ChartError::EmptyData => write!(f, "Cannot plot empty data"),
44 ChartError::InvalidRange => write!(f, "Invalid min/max range"),
45 ChartError::InvalidDimensions => write!(f, "Invalid chart dimensions"),
46 }
47 }
48}
49
50impl std::error::Error for ChartError {}
51
52pub type Result<T> = std::result::Result<T, ChartError>;
53
54#[derive(Debug, Clone)]
56pub struct Config {
57 pub height: usize,
59 pub width: usize,
61 pub offset: usize,
63 pub min: Option<f64>,
65 pub max: Option<f64>,
67 pub show_labels: bool,
69 pub label_ticks: usize,
71 pub label_format: String,
73 pub symbols: Symbols,
75}
76
77#[derive(Debug, Clone)]
79pub struct Symbols {
80 pub horizontal: char,
81 pub vertical: char,
82 pub top_right: char,
83 pub bottom_right: char,
84 pub bottom_left: char,
85 pub top_left: char,
86 pub axis_vertical: char,
87 pub axis_corner: char,
88 pub axis_bottom: char,
89}
90
91impl Default for Symbols {
92 fn default() -> Self {
93 Self {
94 horizontal: '─',
95 vertical: '│',
96 top_right: '╮',
97 bottom_right: '╯',
98 bottom_left: '╰',
99 top_left: '╭',
100 axis_vertical: '│',
101 axis_corner: '┤',
102 axis_bottom: '┴',
103 }
104 }
105}
106
107impl Symbols {
108 pub fn ascii() -> Self {
110 Self {
111 horizontal: '-',
112 vertical: '|',
113 top_right: '+',
114 bottom_right: '+',
115 bottom_left: '+',
116 top_left: '+',
117 axis_vertical: '|',
118 axis_corner: '|',
119 axis_bottom: '+',
120 }
121 }
122}
123
124impl Default for Config {
125 fn default() -> Self {
126 Self {
127 height: 10,
128 width: 80,
129 offset: 3,
130 min: None,
131 max: None,
132 show_labels: true,
133 label_ticks: 5,
134 label_format: "{:.2}".to_string(),
135 symbols: Symbols::default(),
136 }
137 }
138}
139
140impl Config {
141 pub fn new() -> Self {
143 Self::default()
144 }
145
146 pub fn with_height(mut self, height: usize) -> Self {
148 self.height = height;
149 self
150 }
151
152 pub fn with_width(mut self, width: usize) -> Self {
154 self.width = width;
155 self
156 }
157
158 pub fn with_offset(mut self, offset: usize) -> Self {
160 self.offset = offset;
161 self
162 }
163
164 pub fn with_min(mut self, min: f64) -> Self {
166 self.min = Some(min);
167 self
168 }
169
170 pub fn with_max(mut self, max: f64) -> Self {
172 self.max = Some(max);
173 self
174 }
175
176 pub fn with_labels(mut self, show: bool) -> Self {
178 self.show_labels = show;
179 self
180 }
181
182 pub fn with_label_ticks(mut self, ticks: usize) -> Self {
184 self.label_ticks = ticks;
185 self
186 }
187
188 pub fn with_label_format(mut self, format: String) -> Self {
190 self.label_format = format;
191 self
192 }
193
194 pub fn with_ascii_symbols(mut self) -> Self {
196 self.symbols = Symbols::ascii();
197 self
198 }
199
200 pub fn with_symbols(mut self, symbols: Symbols) -> Self {
202 self.symbols = symbols;
203 self
204 }
205
206 pub fn validate(&self) -> Result<()> {
208 if self.height == 0 || self.width == 0 {
209 return Err(ChartError::InvalidDimensions);
210 }
211 if let (Some(min), Some(max)) = (self.min, self.max) {
212 if min >= max {
213 return Err(ChartError::InvalidRange);
214 }
215 }
216 Ok(())
217 }
218}
219
220pub fn plot_with_config(series: &[f64], config: Config) -> Result<String> {
242 config.validate()?;
243
244 if series.is_empty() {
245 return Err(ChartError::EmptyData);
246 }
247
248 if series.len() == 1 {
249 return Ok(format_value(series[0], &config.label_format));
250 }
251
252 let finite_values: Vec<f64> = series.iter()
254 .copied()
255 .filter(|v| v.is_finite())
256 .collect();
257
258 if finite_values.is_empty() {
259 return Err(ChartError::InvalidRange);
260 }
261
262 let min = config.min.unwrap_or_else(|| {
264 finite_values.iter().copied().fold(f64::INFINITY, f64::min)
265 });
266
267 let max = config.max.unwrap_or_else(|| {
268 finite_values.iter().copied().fold(f64::NEG_INFINITY, f64::max)
269 });
270
271 if !min.is_finite() || !max.is_finite() {
272 return Err(ChartError::InvalidRange);
273 }
274
275 if (max - min).abs() < f64::EPSILON {
277 return Ok(format_value(min, &config.label_format));
278 }
279
280 let range = max - min;
281 let height = config.height;
282 let ratio = (height as f64) / range;
283
284 let mut canvas: Vec<Vec<char>> = vec![vec![' '; config.width]; height + 1];
286
287 let mut y0: Option<usize> = None;
289
290 for (x, &value) in series.iter().enumerate().take(config.width.saturating_sub(1)) {
291 if !value.is_finite() {
292 continue;
293 }
294
295 let y = ((max - value) * ratio).round() as usize;
296 let y = y.min(height);
297
298 let plot_x = x + 1; if let Some(y_prev) = y0 {
301 if y == y_prev {
302 canvas[y][plot_x] = config.symbols.horizontal;
304 } else {
305 let (y_start, y_end) = if y_prev < y {
307 (y_prev, y)
308 } else {
309 (y, y_prev)
310 };
311
312 for y_line in y_start..=y_end {
314 if y_line == y_prev {
315 if y_prev < y {
316 canvas[y_line][plot_x] = config.symbols.top_right;
317 } else {
318 canvas[y_line][plot_x] = config.symbols.bottom_right;
319 }
320 } else if y_line == y {
321 if y_prev < y {
322 canvas[y_line][plot_x] = config.symbols.bottom_left;
323 } else {
324 canvas[y_line][plot_x] = config.symbols.top_left;
325 }
326 } else {
327 canvas[y_line][plot_x] = config.symbols.vertical;
328 }
329 }
330 }
331 } else {
332 canvas[y][plot_x] = config.symbols.vertical;
334 }
335
336 y0 = Some(y);
337 }
338
339 let mut lines = Vec::new();
341
342 if config.show_labels {
343 let label_width = format_value(max, &config.label_format).len()
344 .max(format_value(min, &config.label_format).len());
345
346 for (idx, row) in canvas.iter().enumerate() {
347 let y_value = max - (idx as f64 * range / height as f64);
348
349 let label = if idx == 0 {
351 format!("{:>width$}", format_value(max, &config.label_format), width = label_width)
352 } else if idx == height {
353 format!("{:>width$}", format_value(min, &config.label_format), width = label_width)
354 } else if config.label_ticks > 0 && height >= config.label_ticks {
355 let step = height / config.label_ticks;
356 if step > 0 && idx % step == 0 {
357 format!("{:>width$}", format_value(y_value, &config.label_format), width = label_width)
358 } else {
359 " ".repeat(label_width)
360 }
361 } else {
362 " ".repeat(label_width)
363 };
364
365 let mut chart_part = row[1..].to_vec();
373
374 if chart_part.first() == Some(&config.symbols.axis_vertical) {
376 chart_part[0] = ' '; }
378
379 let chart_str: String = chart_part.iter().collect();
380
381 lines.push(format!("{}{}{}", label, config.symbols.axis_vertical, chart_str));
382
383 }
384 } else {
385 for row in canvas.iter() {
386 let line: String = row.iter().collect();
387 lines.push(line);
388 }
389 }
390
391 Ok(lines.join("\n"))
392}
393
394fn format_value(value: f64, format: &str) -> String {
396 if format.contains(":.2") {
398 format!("{:.2}", value)
399 } else if format.contains(":.1") {
400 format!("{:.1}", value)
401 } else if format.contains(":.0") {
402 format!("{:.0}", value)
403 } else {
404 format!("{:.2}", value)
405 }
406}
407
408pub fn plot(series: &[f64]) -> String {
423 plot_with_config(series, Config::default()).unwrap_or_else(|e| e.to_string())
424}
425
426pub fn plot_sized(series: &[f64], height: usize, width: usize) -> String {
437 plot_with_config(
438 series,
439 Config::default().with_height(height).with_width(width)
440 ).unwrap_or_else(|e| e.to_string())
441}
442
443pub fn plot_no_labels(series: &[f64]) -> String {
454 plot_with_config(
455 series,
456 Config::default().with_labels(false)
457 ).unwrap_or_else(|e| e.to_string())
458}
459
460pub fn plot_range(series: &[f64], min: f64, max: f64) -> String {
471 plot_with_config(
472 series,
473 Config::default().with_min(min).with_max(max)
474 ).unwrap_or_else(|e| e.to_string())
475}
476
477pub fn plot_ascii(series: &[f64]) -> String {
488 plot_with_config(
489 series,
490 Config::default().with_ascii_symbols()
491 ).unwrap_or_else(|e| e.to_string())
492}
493
494pub fn plot_multiple(series: &[&[f64]]) -> String {
506 if series.is_empty() {
507 return "No data".to_string();
508 }
509
510 let mut global_min = f64::INFINITY;
512 let mut global_max = f64::NEG_INFINITY;
513
514 for s in series {
515 for &val in *s {
516 if val.is_finite() {
517 global_min = global_min.min(val);
518 global_max = global_max.max(val);
519 }
520 }
521 }
522
523 if !global_min.is_finite() || !global_max.is_finite() {
524 return "Invalid data".to_string();
525 }
526
527 let config = Config::default()
529 .with_min(global_min)
530 .with_max(global_max);
531
532 plot_with_config(series[0], config).unwrap_or_else(|e| e.to_string())
533}
534
535pub fn generate_sine(points: usize, frequency: f64, phase: f64) -> Vec<f64> {
546 (0..points)
547 .map(|i| {
548 let x = i as f64 * 2.0 * std::f64::consts::PI / points as f64;
549 (frequency * x + phase).sin()
550 })
551 .collect()
552}
553
554pub fn generate_cosine(points: usize, frequency: f64, phase: f64) -> Vec<f64> {
556 (0..points)
557 .map(|i| {
558 let x = i as f64 * 2.0 * std::f64::consts::PI / points as f64;
559 (frequency * x + phase).cos()
560 })
561 .collect()
562}
563
564pub fn generate_random_walk(points: usize, start: f64, volatility: f64) -> Vec<f64> {
566 use std::collections::hash_map::RandomState;
567 use std::hash::{BuildHasher, Hash, Hasher};
568
569 let mut result = Vec::with_capacity(points);
570 let mut current = start;
571 result.push(current);
572
573 for i in 1..points {
574 let s = RandomState::new();
576 let mut hasher = s.build_hasher();
577 i.hash(&mut hasher);
578 let hash = hasher.finish();
579 let random = (hash % 1000) as f64 / 1000.0 - 0.5;
580
581 current += random * volatility;
582 result.push(current);
583 }
584
585 result
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn test_simple_plot() {
594 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0, 1.0];
595 let chart = plot(&data);
596 assert!(!chart.is_empty());
597 }
598
599 #[test]
600 fn test_empty_data() {
601 let data: Vec<f64> = vec![];
602 let result = plot_with_config(&data, Config::default());
603 assert!(result.is_err());
604 }
605
606 #[test]
607 fn test_single_value() {
608 let data = vec![5.0];
609 let chart = plot(&data);
610 assert!(chart.contains("5.00"));
611 }
612
613 #[test]
614 fn test_custom_config() {
615 let data = vec![10.0, 20.0, 30.0, 20.0, 10.0];
616 let config = Config::default()
617 .with_height(15)
618 .with_width(50);
619 let chart = plot_with_config(&data, config).unwrap();
620 assert!(!chart.is_empty());
621 }
622
623 #[test]
624 fn test_no_labels() {
625 let data = vec![1.0, 2.0, 3.0];
626 let chart = plot_no_labels(&data);
627 assert!(!chart.is_empty());
628 assert!(!chart.contains("│"));
629 }
630
631 #[test]
632 fn test_ascii_symbols() {
633 let data = vec![1.0, 2.0, 3.0];
634 let chart = plot_ascii(&data);
635 assert!(!chart.is_empty());
636 }
637
638 #[test]
639 fn test_invalid_range() {
640 let config = Config::default().with_min(10.0).with_max(5.0);
641 assert!(config.validate().is_err());
642 }
643
644 #[test]
645 fn test_generate_sine() {
646 let data = generate_sine(50, 1.0, 0.0);
647 assert_eq!(data.len(), 50);
648 assert!(data[0].abs() < 0.1);
649 }
650
651 #[test]
652 fn test_with_nan() {
653 let data = vec![1.0, 2.0, f64::NAN, 4.0, 5.0];
654 let chart = plot(&data);
655 assert!(!chart.is_empty());
656 }
657
658 #[test]
659 fn test_with_infinity() {
660 let data = vec![1.0, 2.0, f64::INFINITY, 4.0, 5.0];
661 let chart = plot(&data);
662 assert!(!chart.is_empty());
663 }
664
665 #[test]
666 fn test_small_range() {
667 let data = vec![1.001, 1.002, 1.003, 1.002, 1.001];
668 let chart = plot(&data);
669 assert!(!chart.is_empty());
670 let line_count = chart.lines().count();
672 assert!(line_count <= 15); }
674
675 #[test]
676 fn test_ascending_line() {
677 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
678 let chart = plot(&data);
679 assert!(chart.contains("╭") || chart.contains("╰"));
681 }
682
683 #[test]
684 fn test_descending_line() {
685 let data = vec![5.0, 4.0, 3.0, 2.0, 1.0];
686 let chart = plot(&data);
687 assert!(chart.contains("╮") || chart.contains("╯"));
689 }
690}