tuiuiu/molecules/
charts.rs

1//! Chart Components
2//!
3//! Data visualization components for terminal.
4
5use crate::core::component::{VNode, BoxNode, BoxStyle, TextStyle, Color, NamedColor};
6
7// =============================================================================
8// Sparkline
9// =============================================================================
10
11/// Sparkline - mini line chart.
12#[derive(Debug, Clone)]
13pub struct Sparkline {
14    data: Vec<f64>,
15    width: Option<u16>,
16    height: u16,
17    color: Color,
18    show_min_max: bool,
19    label: Option<String>,
20}
21
22impl Default for Sparkline {
23    fn default() -> Self {
24        Self {
25            data: Vec::new(),
26            width: None,
27            height: 1,
28            color: Color::Named(NamedColor::Green),
29            show_min_max: false,
30            label: None,
31        }
32    }
33}
34
35impl Sparkline {
36    /// Create a new sparkline.
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Set data points.
42    pub fn data<I>(mut self, data: I) -> Self
43    where
44        I: IntoIterator<Item = f64>,
45    {
46        self.data = data.into_iter().collect();
47        self
48    }
49
50    /// Set data from integers.
51    pub fn data_i32<I>(mut self, data: I) -> Self
52    where
53        I: IntoIterator<Item = i32>,
54    {
55        self.data = data.into_iter().map(|x| x as f64).collect();
56        self
57    }
58
59    /// Set width.
60    pub fn width(mut self, width: u16) -> Self {
61        self.width = Some(width);
62        self
63    }
64
65    /// Set height (number of rows).
66    pub fn height(mut self, height: u16) -> Self {
67        self.height = height;
68        self
69    }
70
71    /// Set color.
72    pub fn color(mut self, color: Color) -> Self {
73        self.color = color;
74        self
75    }
76
77    /// Show min/max values.
78    pub fn show_min_max(mut self) -> Self {
79        self.show_min_max = true;
80        self
81    }
82
83    /// Set label.
84    pub fn label(mut self, label: impl Into<String>) -> Self {
85        self.label = Some(label.into());
86        self
87    }
88
89    /// Build the VNode.
90    pub fn build(self) -> VNode {
91        if self.data.is_empty() {
92            return VNode::styled_text("No data", TextStyle::color(Color::Named(NamedColor::Gray)));
93        }
94
95        // Sparkline characters (from low to high)
96        let chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
97
98        let min = self.data.iter().copied().fold(f64::INFINITY, f64::min);
99        let max = self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
100        let range = max - min;
101
102        let width = self.width.unwrap_or(self.data.len() as u16);
103        let data_len = self.data.len();
104
105        // Resample data if needed
106        let samples: Vec<f64> = if data_len == width as usize {
107            self.data.clone()
108        } else {
109            (0..width as usize)
110                .map(|i| {
111                    let idx = (i * data_len) / width as usize;
112                    self.data.get(idx).copied().unwrap_or(0.0)
113                })
114                .collect()
115        };
116
117        // Convert to sparkline characters
118        let sparkline: String = samples
119            .iter()
120            .map(|&v| {
121                if range == 0.0 {
122                    chars[4]
123                } else {
124                    let normalized = ((v - min) / range * 7.0) as usize;
125                    chars[normalized.min(7)]
126                }
127            })
128            .collect();
129
130        let mut children = Vec::new();
131
132        if let Some(label) = &self.label {
133            children.push(VNode::styled_text(label.clone(), TextStyle::bold()));
134        }
135
136        children.push(VNode::styled_text(sparkline, TextStyle::color(self.color)));
137
138        if self.show_min_max {
139            children.push(VNode::styled_text(
140                format!("min: {:.1} max: {:.1}", min, max),
141                TextStyle { color: Some(Color::Named(NamedColor::Gray)), dim: true, ..Default::default() }
142            ));
143        }
144
145        VNode::Box(BoxNode {
146            children,
147            style: BoxStyle::default(),
148            ..Default::default()
149        })
150    }
151}
152
153// =============================================================================
154// BarChart
155// =============================================================================
156
157/// Bar orientation.
158#[derive(Debug, Clone, Copy, Default)]
159pub enum BarOrientation {
160    #[default]
161    Horizontal,
162    Vertical,
163}
164
165/// A single bar item.
166#[derive(Debug, Clone)]
167pub struct BarItem {
168    /// Label
169    pub label: String,
170    /// Value
171    pub value: f64,
172    /// Color override
173    pub color: Option<Color>,
174}
175
176impl BarItem {
177    /// Create a new bar item.
178    pub fn new(label: impl Into<String>, value: f64) -> Self {
179        Self {
180            label: label.into(),
181            value,
182            color: None,
183        }
184    }
185
186    /// Set color.
187    pub fn color(mut self, color: Color) -> Self {
188        self.color = Some(color);
189        self
190    }
191}
192
193/// Bar chart component.
194#[derive(Debug, Clone)]
195pub struct BarChart {
196    items: Vec<BarItem>,
197    width: u16,
198    bar_width: u16,
199    orientation: BarOrientation,
200    color: Color,
201    show_values: bool,
202    max_value: Option<f64>,
203}
204
205impl Default for BarChart {
206    fn default() -> Self {
207        Self {
208            items: Vec::new(),
209            width: 40,
210            bar_width: 1,
211            orientation: BarOrientation::Horizontal,
212            color: Color::Named(NamedColor::Cyan),
213            show_values: true,
214            max_value: None,
215        }
216    }
217}
218
219impl BarChart {
220    /// Create a new bar chart.
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Set bar items.
226    pub fn items<I>(mut self, items: I) -> Self
227    where
228        I: IntoIterator<Item = BarItem>,
229    {
230        self.items = items.into_iter().collect();
231        self
232    }
233
234    /// Set data from label-value pairs.
235    pub fn data<I, S>(mut self, data: I) -> Self
236    where
237        I: IntoIterator<Item = (S, f64)>,
238        S: Into<String>,
239    {
240        self.items = data.into_iter().map(|(l, v)| BarItem::new(l, v)).collect();
241        self
242    }
243
244    /// Set chart width.
245    pub fn width(mut self, width: u16) -> Self {
246        self.width = width;
247        self
248    }
249
250    /// Set bar width (height in horizontal mode).
251    pub fn bar_width(mut self, width: u16) -> Self {
252        self.bar_width = width;
253        self
254    }
255
256    /// Set orientation.
257    pub fn orientation(mut self, orientation: BarOrientation) -> Self {
258        self.orientation = orientation;
259        self
260    }
261
262    /// Use horizontal bars.
263    pub fn horizontal(self) -> Self {
264        self.orientation(BarOrientation::Horizontal)
265    }
266
267    /// Use vertical bars.
268    pub fn vertical(self) -> Self {
269        self.orientation(BarOrientation::Vertical)
270    }
271
272    /// Set default color.
273    pub fn color(mut self, color: Color) -> Self {
274        self.color = color;
275        self
276    }
277
278    /// Show values on bars.
279    pub fn show_values(mut self, show: bool) -> Self {
280        self.show_values = show;
281        self
282    }
283
284    /// Set maximum value (for scaling).
285    pub fn max(mut self, max: f64) -> Self {
286        self.max_value = Some(max);
287        self
288    }
289
290    /// Build the VNode.
291    pub fn build(self) -> VNode {
292        if self.items.is_empty() {
293            return VNode::styled_text("No data", TextStyle::color(Color::Named(NamedColor::Gray)));
294        }
295
296        let max_value = self.max_value.unwrap_or_else(|| {
297            self.items.iter().map(|i| i.value).fold(0.0, f64::max)
298        });
299
300        let max_label_len = self.items.iter().map(|i| i.label.len()).max().unwrap_or(0);
301
302        let mut children = Vec::new();
303
304        match self.orientation {
305            BarOrientation::Horizontal => {
306                for item in &self.items {
307                    let bar_len = if max_value > 0.0 {
308                        ((item.value / max_value) * (self.width as f64 - max_label_len as f64 - 4.0)) as usize
309                    } else {
310                        0
311                    };
312
313                    let bar = "█".repeat(bar_len);
314                    let color = item.color.unwrap_or(self.color);
315
316                    let value_str = if self.show_values {
317                        format!(" {:.1}", item.value)
318                    } else {
319                        String::new()
320                    };
321
322                    let line = format!(
323                        "{:>width$} │{}{}",
324                        item.label,
325                        bar,
326                        value_str,
327                        width = max_label_len
328                    );
329
330                    children.push(VNode::styled_text(line, TextStyle::color(color)));
331                }
332            }
333            BarOrientation::Vertical => {
334                // Vertical bars - more complex, using block characters
335                let height = 8;
336                let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
337
338                for row in (0..height).rev() {
339                    let threshold = (row as f64 + 1.0) / height as f64;
340
341                    let mut row_text = String::new();
342                    for item in &self.items {
343                        let normalized = if max_value > 0.0 { item.value / max_value } else { 0.0 };
344
345                        let char = if normalized >= threshold {
346                            '█'
347                        } else if normalized > threshold - (1.0 / height as f64) {
348                            let partial = ((normalized - (threshold - 1.0 / height as f64)) * height as f64 * 8.0) as usize;
349                            bar_chars[partial.min(7)]
350                        } else {
351                            ' '
352                        };
353
354                        row_text.push(char);
355                        row_text.push(' ');
356                    }
357
358                    children.push(VNode::styled_text(row_text, TextStyle::color(self.color)));
359                }
360
361                // Labels
362                let labels: String = self.items.iter()
363                    .map(|i| format!("{:.1}", i.label.chars().next().unwrap_or(' ')))
364                    .collect::<Vec<_>>()
365                    .join(" ");
366
367                children.push(VNode::styled_text(labels, TextStyle::color(Color::Named(NamedColor::Gray))));
368            }
369        }
370
371        VNode::Box(BoxNode {
372            children,
373            style: BoxStyle::default(),
374            ..Default::default()
375        })
376    }
377}
378
379// =============================================================================
380// Gauge
381// =============================================================================
382
383/// Gauge style.
384#[derive(Debug, Clone, Copy, Default)]
385pub enum GaugeStyle {
386    #[default]
387    Bar,
388    Arc,
389    Circle,
390}
391
392/// Gauge component - shows a value as a percentage.
393#[derive(Debug, Clone)]
394pub struct Gauge {
395    value: f64,
396    max: f64,
397    min: f64,
398    width: u16,
399    style: GaugeStyle,
400    label: Option<String>,
401    show_percentage: bool,
402    color: Color,
403    #[allow(dead_code)]
404    background_color: Color,
405    thresholds: Vec<(f64, Color)>,
406}
407
408impl Default for Gauge {
409    fn default() -> Self {
410        Self {
411            value: 0.0,
412            max: 100.0,
413            min: 0.0,
414            width: 20,
415            style: GaugeStyle::Bar,
416            label: None,
417            show_percentage: true,
418            color: Color::Named(NamedColor::Green),
419            background_color: Color::Named(NamedColor::BrightBlack),
420            thresholds: Vec::new(),
421        }
422    }
423}
424
425impl Gauge {
426    /// Create a new gauge.
427    pub fn new() -> Self {
428        Self::default()
429    }
430
431    /// Set value.
432    pub fn value(mut self, value: f64) -> Self {
433        self.value = value;
434        self
435    }
436
437    /// Set maximum.
438    pub fn max(mut self, max: f64) -> Self {
439        self.max = max;
440        self
441    }
442
443    /// Set minimum.
444    pub fn min(mut self, min: f64) -> Self {
445        self.min = min;
446        self
447    }
448
449    /// Set width.
450    pub fn width(mut self, width: u16) -> Self {
451        self.width = width;
452        self
453    }
454
455    /// Set style.
456    pub fn style(mut self, style: GaugeStyle) -> Self {
457        self.style = style;
458        self
459    }
460
461    /// Set label.
462    pub fn label(mut self, label: impl Into<String>) -> Self {
463        self.label = Some(label.into());
464        self
465    }
466
467    /// Show percentage.
468    pub fn show_percentage(mut self, show: bool) -> Self {
469        self.show_percentage = show;
470        self
471    }
472
473    /// Set color.
474    pub fn color(mut self, color: Color) -> Self {
475        self.color = color;
476        self
477    }
478
479    /// Add color threshold.
480    pub fn threshold(mut self, value: f64, color: Color) -> Self {
481        self.thresholds.push((value, color));
482        self.thresholds.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
483        self
484    }
485
486    /// Add common thresholds (green/yellow/red).
487    pub fn traffic_light(self) -> Self {
488        self.threshold(50.0, Color::Named(NamedColor::Green))
489            .threshold(75.0, Color::Named(NamedColor::Yellow))
490            .threshold(90.0, Color::Named(NamedColor::Red))
491    }
492
493    /// Get color based on value and thresholds.
494    fn get_color(&self) -> Color {
495        for (threshold, color) in self.thresholds.iter().rev() {
496            if self.value >= *threshold {
497                return *color;
498            }
499        }
500        self.color
501    }
502
503    /// Build the VNode.
504    pub fn build(self) -> VNode {
505        let range = self.max - self.min;
506        let percentage = if range > 0.0 {
507            ((self.value - self.min) / range * 100.0).clamp(0.0, 100.0)
508        } else {
509            0.0
510        };
511
512        let filled = ((percentage / 100.0) * self.width as f64) as usize;
513        let empty = self.width as usize - filled;
514
515        let color = self.get_color();
516
517        let bar = match self.style {
518            GaugeStyle::Bar => {
519                let filled_str = "█".repeat(filled);
520                let empty_str = "░".repeat(empty);
521                format!("{}{}", filled_str, empty_str)
522            }
523            GaugeStyle::Arc => {
524                // Simple arc representation
525                let chars: Vec<char> = (0..self.width)
526                    .map(|i| {
527                        if (i as f64) < (self.width as f64 * percentage / 100.0) {
528                            '◼'
529                        } else {
530                            '◻'
531                        }
532                    })
533                    .collect();
534                chars.into_iter().collect()
535            }
536            GaugeStyle::Circle => {
537                // Pie-like representation using Unicode
538                let segments = 8;
539                let filled_segments = (percentage / 100.0 * segments as f64) as usize;
540                let pie_chars = ['○', '◔', '◑', '◕', '●'];
541                let idx = (filled_segments * pie_chars.len() / segments).min(pie_chars.len() - 1);
542                pie_chars[idx].to_string()
543            }
544        };
545
546        let mut content = String::new();
547
548        if let Some(label) = &self.label {
549            content.push_str(label);
550            content.push_str(": ");
551        }
552
553        content.push_str(&bar);
554
555        if self.show_percentage {
556            content.push_str(&format!(" {:.0}%", percentage));
557        }
558
559        VNode::styled_text(content, TextStyle::color(color))
560    }
561}
562
563// =============================================================================
564// LineChart
565// =============================================================================
566
567/// Line chart component.
568#[derive(Debug, Clone)]
569pub struct LineChart {
570    data: Vec<Vec<f64>>,
571    labels: Vec<String>,
572    width: u16,
573    height: u16,
574    #[allow(dead_code)]
575    colors: Vec<Color>,
576    show_legend: bool,
577}
578
579impl Default for LineChart {
580    fn default() -> Self {
581        Self {
582            data: Vec::new(),
583            labels: Vec::new(),
584            width: 40,
585            height: 10,
586            colors: vec![
587                Color::Named(NamedColor::Cyan),
588                Color::Named(NamedColor::Green),
589                Color::Named(NamedColor::Yellow),
590                Color::Named(NamedColor::Magenta),
591            ],
592            show_legend: true,
593        }
594    }
595}
596
597impl LineChart {
598    /// Create a new line chart.
599    pub fn new() -> Self {
600        Self::default()
601    }
602
603    /// Add a data series.
604    pub fn series<I>(mut self, label: impl Into<String>, data: I) -> Self
605    where
606        I: IntoIterator<Item = f64>,
607    {
608        self.labels.push(label.into());
609        self.data.push(data.into_iter().collect());
610        self
611    }
612
613    /// Set dimensions.
614    pub fn size(mut self, width: u16, height: u16) -> Self {
615        self.width = width;
616        self.height = height;
617        self
618    }
619
620    /// Show legend.
621    pub fn legend(mut self, show: bool) -> Self {
622        self.show_legend = show;
623        self
624    }
625
626    /// Build the VNode.
627    pub fn build(self) -> VNode {
628        if self.data.is_empty() {
629            return VNode::styled_text("No data", TextStyle::color(Color::Named(NamedColor::Gray)));
630        }
631
632        // Find global min/max
633        let all_values: Vec<f64> = self.data.iter().flatten().copied().collect();
634        let min = all_values.iter().copied().fold(f64::INFINITY, f64::min);
635        let max = all_values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
636        let range = max - min;
637
638        // Create a simple ASCII line chart
639        let mut grid = vec![vec![' '; self.width as usize]; self.height as usize];
640
641        // Plot each series
642        for (series_idx, series) in self.data.iter().enumerate() {
643            let char = ['●', '◆', '■', '▲'][series_idx % 4];
644
645            for (i, &value) in series.iter().enumerate() {
646                let x = (i * self.width as usize) / series.len().max(1);
647                let y = if range > 0.0 {
648                    ((max - value) / range * (self.height - 1) as f64) as usize
649                } else {
650                    self.height as usize / 2
651                };
652
653                if x < self.width as usize && y < self.height as usize {
654                    grid[y][x] = char;
655                }
656            }
657        }
658
659        let mut children: Vec<VNode> = grid.into_iter().map(|row| {
660            VNode::styled_text(
661                row.into_iter().collect::<String>(),
662                TextStyle::color(Color::Named(NamedColor::White))
663            )
664        }).collect();
665
666        // Legend
667        if self.show_legend && !self.labels.is_empty() {
668            let legend_parts: Vec<String> = self.labels.iter().enumerate()
669                .map(|(i, label)| {
670                    let char = ['●', '◆', '■', '▲'][i % 4];
671                    format!("{} {}", char, label)
672                })
673                .collect();
674
675            children.push(VNode::styled_text(
676                legend_parts.join("  "),
677                TextStyle { color: Some(Color::Named(NamedColor::Gray)), dim: true, ..Default::default() }
678            ));
679        }
680
681        VNode::Box(BoxNode {
682            children,
683            style: BoxStyle::default(),
684            ..Default::default()
685        })
686    }
687}
688
689// =============================================================================
690// Heatmap
691// =============================================================================
692
693/// Heatmap component.
694#[derive(Debug, Clone)]
695pub struct Heatmap {
696    data: Vec<Vec<f64>>,
697    row_labels: Vec<String>,
698    col_labels: Vec<String>,
699    #[allow(dead_code)]
700    colors: Vec<Color>,
701}
702
703impl Default for Heatmap {
704    fn default() -> Self {
705        Self {
706            data: Vec::new(),
707            row_labels: Vec::new(),
708            col_labels: Vec::new(),
709            colors: vec![
710                Color::Named(NamedColor::Blue),
711                Color::Named(NamedColor::Cyan),
712                Color::Named(NamedColor::Green),
713                Color::Named(NamedColor::Yellow),
714                Color::Named(NamedColor::Red),
715            ],
716        }
717    }
718}
719
720impl Heatmap {
721    /// Create a new heatmap.
722    pub fn new() -> Self {
723        Self::default()
724    }
725
726    /// Set data.
727    pub fn data(mut self, data: Vec<Vec<f64>>) -> Self {
728        self.data = data;
729        self
730    }
731
732    /// Set row labels.
733    pub fn rows<I, S>(mut self, labels: I) -> Self
734    where
735        I: IntoIterator<Item = S>,
736        S: Into<String>,
737    {
738        self.row_labels = labels.into_iter().map(Into::into).collect();
739        self
740    }
741
742    /// Set column labels.
743    pub fn cols<I, S>(mut self, labels: I) -> Self
744    where
745        I: IntoIterator<Item = S>,
746        S: Into<String>,
747    {
748        self.col_labels = labels.into_iter().map(Into::into).collect();
749        self
750    }
751
752    /// Build the VNode.
753    pub fn build(self) -> VNode {
754        if self.data.is_empty() {
755            return VNode::styled_text("No data", TextStyle::color(Color::Named(NamedColor::Gray)));
756        }
757
758        let heat_chars = ['░', '▒', '▓', '█'];
759
760        let all_values: Vec<f64> = self.data.iter().flatten().copied().collect();
761        let min = all_values.iter().copied().fold(f64::INFINITY, f64::min);
762        let max = all_values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
763        let range = max - min;
764
765        let mut children = Vec::new();
766
767        for (row_idx, row) in self.data.iter().enumerate() {
768            let row_label = self.row_labels.get(row_idx)
769                .map(|s| format!("{:>8} ", s))
770                .unwrap_or_default();
771
772            let cells: String = row.iter().map(|&v| {
773                let normalized = if range > 0.0 { (v - min) / range } else { 0.5 };
774                let idx = (normalized * (heat_chars.len() - 1) as f64) as usize;
775                heat_chars[idx.min(heat_chars.len() - 1)]
776            }).collect();
777
778            children.push(VNode::styled_text(
779                format!("{}{}", row_label, cells),
780                TextStyle::color(Color::Named(NamedColor::Yellow))
781            ));
782        }
783
784        VNode::Box(BoxNode {
785            children,
786            style: BoxStyle::default(),
787            ..Default::default()
788        })
789    }
790}