rasciichart/
lib.rs

1// File: rasciichart/src/lib.rs
2// ASCII Chart Library - Smooth output seperti asciichartpy
3// Author: Hadi Cahyadi <cumulus13@gmail.com>
4// License: MIT
5
6//! # rasciichart
7//!
8//! A Rust library for creating beautiful ASCII charts in the terminal.
9//! Inspired by asciichartpy, this library provides smooth line rendering
10//! with extensive customization options.
11//!
12//! ## Features
13//!
14//! - Simple and intuitive API
15//! - Smooth line rendering with box-drawing characters
16//! - Customizable height, width, and axis labels
17//! - Support for multiple data series
18//! - Helper functions for common use cases
19//! - Zero external dependencies
20//!
21//! ## Quick Start
22//!
23//! ```rust
24//! use rasciichart::plot;
25//!
26//! let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0, 1.0];
27//! println!("{}", plot(&data));
28//! ```
29
30use std::fmt;
31
32/// Error types for the library
33#[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/// Configuration for chart rendering
55#[derive(Debug, Clone)]
56pub struct Config {
57    /// Height of the chart in rows
58    pub height: usize,
59    /// Width of the chart in columns
60    pub width: usize,
61    /// Offset for labels (left margin)
62    pub offset: usize,
63    /// Minimum Y-axis value (auto-calculated if None)
64    pub min: Option<f64>,
65    /// Maximum Y-axis value (auto-calculated if None)
66    pub max: Option<f64>,
67    /// Show Y-axis labels
68    pub show_labels: bool,
69    /// Number of Y-axis label ticks
70    pub label_ticks: usize,
71    /// Format string for Y-axis labels
72    pub label_format: String,
73    /// Characters to use for drawing
74    pub symbols: Symbols,
75}
76
77/// Symbols used for drawing the chart
78#[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    /// ASCII-only symbols for compatibility
109    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    /// Create a new Config with default values
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Set the chart height
147    pub fn with_height(mut self, height: usize) -> Self {
148        self.height = height;
149        self
150    }
151
152    /// Set the chart width
153    pub fn with_width(mut self, width: usize) -> Self {
154        self.width = width;
155        self
156    }
157
158    /// Set the label offset
159    pub fn with_offset(mut self, offset: usize) -> Self {
160        self.offset = offset;
161        self
162    }
163
164    /// Set the minimum Y-axis value
165    pub fn with_min(mut self, min: f64) -> Self {
166        self.min = Some(min);
167        self
168    }
169
170    /// Set the maximum Y-axis value
171    pub fn with_max(mut self, max: f64) -> Self {
172        self.max = Some(max);
173        self
174    }
175
176    /// Set whether to show Y-axis labels
177    pub fn with_labels(mut self, show: bool) -> Self {
178        self.show_labels = show;
179        self
180    }
181
182    /// Set the number of Y-axis label ticks
183    pub fn with_label_ticks(mut self, ticks: usize) -> Self {
184        self.label_ticks = ticks;
185        self
186    }
187
188    /// Set the label format string
189    pub fn with_label_format(mut self, format: String) -> Self {
190        self.label_format = format;
191        self
192    }
193
194    /// Use ASCII-only symbols
195    pub fn with_ascii_symbols(mut self) -> Self {
196        self.symbols = Symbols::ascii();
197        self
198    }
199
200    /// Set custom symbols
201    pub fn with_symbols(mut self, symbols: Symbols) -> Self {
202        self.symbols = symbols;
203        self
204    }
205
206    /// Validate the configuration
207    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
220/// Main plotting function - creates an ASCII chart from a data series
221///
222/// # Arguments
223///
224/// * `series` - A slice of f64 values to plot
225/// * `config` - Configuration for chart rendering
226///
227/// # Returns
228///
229/// A String containing the rendered ASCII chart
230///
231/// # Example
232///
233/// ```rust
234/// use rasciichart::{plot_with_config, Config};
235///
236/// let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
237/// let config = Config::new().with_height(15).with_width(60);
238/// let chart = plot_with_config(&data, config).unwrap();
239/// println!("{}", chart);
240/// ```
241pub 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    // Filter out non-finite values for min/max calculation
253    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    // Determine min and max
263    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    // Handle case where all values are the same
276    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    // Initialize canvas - no extra width needed
285    let mut canvas: Vec<Vec<char>> = vec![vec![' '; config.width]; height + 1];
286
287    // Plot the line - SKIP x=0 (reserved for axis separator)
288    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; // Start from x=1, skip x=0
299
300        if let Some(y_prev) = y0 {
301            if y == y_prev {
302                // Horizontal line
303                canvas[y][plot_x] = config.symbols.horizontal;
304            } else {
305                // Vertical movement
306                let (y_start, y_end) = if y_prev < y {
307                    (y_prev, y)
308                } else {
309                    (y, y_prev)
310                };
311
312                // Draw vertical connection
313                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            // First point
333            canvas[y][plot_x] = config.symbols.vertical;
334        }
335
336        y0 = Some(y);
337    }
338
339    // Build output with Y-axis labels
340    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            // Determine if this row should have a label
350            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 line: String = row.iter().collect();
366            // label + │ + chart, x=0 is always space so no double │
367            // lines.push(format!("{}│{}", label, &line[1..]));
368            // Make sure the line starts from column 1 (chart starts here)
369            // let chart_part: String = row[1..].iter().collect();
370            // lines.push(format!("{}{}{}", label, config.symbols.axis_vertical, chart_part));
371
372            let mut chart_part = row[1..].to_vec();
373
374            // If the first chart row is vertical ('│'), replace it so that it is not double axis
375            if chart_part.first() == Some(&config.symbols.axis_vertical) {
376                chart_part[0] = ' '; // atau hapus: chart_part.remove(0);
377            }
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
394/// Format a value according to the format string
395fn format_value(value: f64, format: &str) -> String {
396    // Simple implementation - extend as needed
397    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
408// ============================================================================
409// Helper Functions
410// ============================================================================
411
412/// Simple plot function with default config
413///
414/// # Example
415///
416/// ```rust
417/// use rasciichart::plot;
418///
419/// let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0, 1.0];
420/// println!("{}", plot(&data));
421/// ```
422pub fn plot(series: &[f64]) -> String {
423    plot_with_config(series, Config::default()).unwrap_or_else(|e| e.to_string())
424}
425
426/// Plot with custom height and width
427///
428/// # Example
429///
430/// ```rust
431/// use rasciichart::plot_sized;
432///
433/// let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
434/// println!("{}", plot_sized(&data, 15, 60));
435/// ```
436pub 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
443/// Plot without Y-axis labels
444///
445/// # Example
446///
447/// ```rust
448/// use rasciichart::plot_no_labels;
449///
450/// let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
451/// println!("{}", plot_no_labels(&data));
452/// ```
453pub 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
460/// Plot with custom min and max values
461///
462/// # Example
463///
464/// ```rust
465/// use rasciichart::plot_range;
466///
467/// let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
468/// println!("{}", plot_range(&data, 0.0, 10.0));
469/// ```
470pub 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
477/// Plot using ASCII-only characters for better compatibility
478///
479/// # Example
480///
481/// ```rust
482/// use rasciichart::plot_ascii;
483///
484/// let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
485/// println!("{}", plot_ascii(&data));
486/// ```
487pub 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
494/// Plot multiple series on the same chart (overlaid)
495///
496/// # Example
497///
498/// ```rust
499/// use rasciichart::plot_multiple;
500///
501/// let series1 = vec![1.0, 2.0, 3.0, 4.0, 5.0];
502/// let series2 = vec![5.0, 4.0, 3.0, 2.0, 1.0];
503/// println!("{}", plot_multiple(&[&series1, &series2]));
504/// ```
505pub fn plot_multiple(series: &[&[f64]]) -> String {
506    if series.is_empty() {
507        return "No data".to_string();
508    }
509
510    // Find global min and max
511    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    // Plot first series with global min/max
528    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
535/// Generate sine wave data for testing
536///
537/// # Example
538///
539/// ```rust
540/// use rasciichart::{generate_sine, plot};
541///
542/// let data = generate_sine(50, 1.0, 0.0);
543/// println!("{}", plot(&data));
544/// ```
545pub 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
554/// Generate cosine wave data for testing
555pub 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
564/// Generate random walk data for testing
565pub 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        // Simple pseudo-random using hash
575        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        // Should not be excessively tall
671        let line_count = chart.lines().count();
672        assert!(line_count <= 15); // Default height + 1
673    }
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        // Should contain ascending characters
680        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        // Should contain descending characters
688        assert!(chart.contains("╮") || chart.contains("╯"));
689    }
690}