Skip to main content

envision/component/chart/
mod.rs

1//! Chart components for data visualization.
2//!
3//! Provides line charts (sparkline with labels) and bar charts
4//! (horizontal/vertical) with data series, labels, colors, and
5//! auto-scaling axes.
6//!
7//! # Example
8//!
9//! ```rust
10//! use envision::component::{
11//!     Component, Chart, ChartState, ChartMessage, DataSeries, ChartKind,
12//! };
13//! use ratatui::style::Color;
14//!
15//! let series = DataSeries::new("Temperature", vec![20.0, 22.0, 25.0, 23.0])
16//!     .with_color(Color::Red);
17//! let mut state = ChartState::line(vec![series]);
18//! assert_eq!(state.series().len(), 1);
19//! assert_eq!(state.kind(), &ChartKind::Line);
20//! ```
21
22use std::marker::PhantomData;
23
24use ratatui::prelude::*;
25use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Sparkline};
26
27use super::Component;
28use crate::input::{Event, KeyCode};
29use crate::theme::Theme;
30
31/// A named data series with values and styling.
32#[derive(Clone, Debug, PartialEq)]
33pub struct DataSeries {
34    /// The series label.
35    label: String,
36    /// The data values.
37    values: Vec<f64>,
38    /// The display color.
39    color: Color,
40}
41
42impl DataSeries {
43    /// Creates a new data series.
44    ///
45    /// # Example
46    ///
47    /// ```rust
48    /// use envision::component::DataSeries;
49    ///
50    /// let series = DataSeries::new("CPU", vec![10.0, 20.0, 30.0]);
51    /// assert_eq!(series.label(), "CPU");
52    /// assert_eq!(series.values(), &[10.0, 20.0, 30.0]);
53    /// ```
54    pub fn new(label: impl Into<String>, values: Vec<f64>) -> Self {
55        Self {
56            label: label.into(),
57            values,
58            color: Color::Cyan,
59        }
60    }
61
62    /// Sets the color (builder pattern).
63    pub fn with_color(mut self, color: Color) -> Self {
64        self.color = color;
65        self
66    }
67
68    /// Returns the label.
69    pub fn label(&self) -> &str {
70        &self.label
71    }
72
73    /// Returns the values.
74    pub fn values(&self) -> &[f64] {
75        &self.values
76    }
77
78    /// Returns the color.
79    pub fn color(&self) -> Color {
80        self.color
81    }
82
83    /// Appends a value.
84    pub fn push(&mut self, value: f64) {
85        self.values.push(value);
86    }
87
88    /// Appends a value, removing the oldest if over max length.
89    pub fn push_bounded(&mut self, value: f64, max_len: usize) {
90        self.values.push(value);
91        while self.values.len() > max_len {
92            self.values.remove(0);
93        }
94    }
95
96    /// Returns the minimum value, or 0.0 if empty.
97    pub fn min(&self) -> f64 {
98        self.values.iter().copied().reduce(f64::min).unwrap_or(0.0)
99    }
100
101    /// Returns the maximum value, or 0.0 if empty.
102    pub fn max(&self) -> f64 {
103        self.values.iter().copied().reduce(f64::max).unwrap_or(0.0)
104    }
105
106    /// Returns the most recent value.
107    pub fn last(&self) -> Option<f64> {
108        self.values.last().copied()
109    }
110
111    /// Returns the number of data points.
112    pub fn len(&self) -> usize {
113        self.values.len()
114    }
115
116    /// Returns true if the series has no data points.
117    pub fn is_empty(&self) -> bool {
118        self.values.is_empty()
119    }
120
121    /// Clears all values.
122    pub fn clear(&mut self) {
123        self.values.clear();
124    }
125
126    /// Sets the label.
127    pub fn set_label(&mut self, label: impl Into<String>) {
128        self.label = label.into();
129    }
130
131    /// Sets the color.
132    pub fn set_color(&mut self, color: Color) {
133        self.color = color;
134    }
135}
136
137/// The kind of chart to display.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub enum ChartKind {
140    /// A line chart (sparkline-style).
141    Line,
142    /// A vertical bar chart.
143    BarVertical,
144    /// A horizontal bar chart.
145    BarHorizontal,
146}
147
148/// Messages that can be sent to a Chart.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub enum ChartMessage {
151    /// Cycle to the next series (for multi-series line charts).
152    NextSeries,
153    /// Cycle to the previous series.
154    PrevSeries,
155}
156
157/// Output messages from a Chart.
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub enum ChartOutput {
160    /// The active series changed.
161    ActiveSeriesChanged(usize),
162}
163
164/// State for a Chart component.
165///
166/// Contains the data series, chart kind, and display options.
167#[derive(Clone, Debug, PartialEq)]
168pub struct ChartState {
169    /// The data series to display.
170    series: Vec<DataSeries>,
171    /// The chart kind.
172    kind: ChartKind,
173    /// Index of the active (highlighted) series.
174    active_series: usize,
175    /// Optional title.
176    title: Option<String>,
177    /// X-axis label.
178    x_label: Option<String>,
179    /// Y-axis label.
180    y_label: Option<String>,
181    /// Whether to show the legend.
182    show_legend: bool,
183    /// Maximum data points to display (for line charts).
184    max_display_points: usize,
185    /// Bar width for bar charts.
186    bar_width: u16,
187    /// Bar gap for bar charts.
188    bar_gap: u16,
189    /// Whether the component is focused.
190    focused: bool,
191    /// Whether the component is disabled.
192    disabled: bool,
193}
194
195impl Default for ChartState {
196    fn default() -> Self {
197        Self {
198            series: Vec::new(),
199            kind: ChartKind::Line,
200            active_series: 0,
201            title: None,
202            x_label: None,
203            y_label: None,
204            show_legend: true,
205            max_display_points: 50,
206            bar_width: 3,
207            bar_gap: 1,
208            focused: false,
209            disabled: false,
210        }
211    }
212}
213
214impl ChartState {
215    /// Creates a line chart state with the given series.
216    ///
217    /// # Example
218    ///
219    /// ```rust
220    /// use envision::component::{ChartState, DataSeries};
221    ///
222    /// let state = ChartState::line(vec![
223    ///     DataSeries::new("Series A", vec![1.0, 2.0, 3.0]),
224    /// ]);
225    /// assert_eq!(state.series().len(), 1);
226    /// ```
227    pub fn line(series: Vec<DataSeries>) -> Self {
228        Self {
229            series,
230            kind: ChartKind::Line,
231            ..Default::default()
232        }
233    }
234
235    /// Creates a vertical bar chart state.
236    pub fn bar_vertical(series: Vec<DataSeries>) -> Self {
237        Self {
238            series,
239            kind: ChartKind::BarVertical,
240            ..Default::default()
241        }
242    }
243
244    /// Creates a horizontal bar chart state.
245    pub fn bar_horizontal(series: Vec<DataSeries>) -> Self {
246        Self {
247            series,
248            kind: ChartKind::BarHorizontal,
249            ..Default::default()
250        }
251    }
252
253    /// Sets the title (builder pattern).
254    pub fn with_title(mut self, title: impl Into<String>) -> Self {
255        self.title = Some(title.into());
256        self
257    }
258
259    /// Sets the X-axis label (builder pattern).
260    pub fn with_x_label(mut self, label: impl Into<String>) -> Self {
261        self.x_label = Some(label.into());
262        self
263    }
264
265    /// Sets the Y-axis label (builder pattern).
266    pub fn with_y_label(mut self, label: impl Into<String>) -> Self {
267        self.y_label = Some(label.into());
268        self
269    }
270
271    /// Sets whether to show the legend (builder pattern).
272    pub fn with_legend(mut self, show: bool) -> Self {
273        self.show_legend = show;
274        self
275    }
276
277    /// Sets the maximum display points for line charts (builder pattern).
278    pub fn with_max_display_points(mut self, max: usize) -> Self {
279        self.max_display_points = max;
280        self
281    }
282
283    /// Sets the bar width (builder pattern).
284    pub fn with_bar_width(mut self, width: u16) -> Self {
285        self.bar_width = width.max(1);
286        self
287    }
288
289    /// Sets the bar gap (builder pattern).
290    pub fn with_bar_gap(mut self, gap: u16) -> Self {
291        self.bar_gap = gap;
292        self
293    }
294
295    /// Sets the disabled state (builder pattern).
296    pub fn with_disabled(mut self, disabled: bool) -> Self {
297        self.disabled = disabled;
298        self
299    }
300
301    // ---- Accessors ----
302
303    /// Returns the data series.
304    pub fn series(&self) -> &[DataSeries] {
305        &self.series
306    }
307
308    /// Returns a mutable reference to the series.
309    pub fn series_mut(&mut self) -> &mut [DataSeries] {
310        &mut self.series
311    }
312
313    /// Returns the series at the given index.
314    pub fn get_series(&self, index: usize) -> Option<&DataSeries> {
315        self.series.get(index)
316    }
317
318    /// Returns a mutable reference to the series at the given index.
319    pub fn get_series_mut(&mut self, index: usize) -> Option<&mut DataSeries> {
320        self.series.get_mut(index)
321    }
322
323    /// Returns the chart kind.
324    pub fn kind(&self) -> &ChartKind {
325        &self.kind
326    }
327
328    /// Sets the chart kind.
329    pub fn set_kind(&mut self, kind: ChartKind) {
330        self.kind = kind;
331    }
332
333    /// Returns the active series index.
334    pub fn active_series(&self) -> usize {
335        self.active_series
336    }
337
338    /// Returns the title.
339    pub fn title(&self) -> Option<&str> {
340        self.title.as_deref()
341    }
342
343    /// Sets the title.
344    pub fn set_title(&mut self, title: Option<String>) {
345        self.title = title;
346    }
347
348    /// Returns the X-axis label.
349    pub fn x_label(&self) -> Option<&str> {
350        self.x_label.as_deref()
351    }
352
353    /// Returns the Y-axis label.
354    pub fn y_label(&self) -> Option<&str> {
355        self.y_label.as_deref()
356    }
357
358    /// Returns whether the legend is shown.
359    pub fn show_legend(&self) -> bool {
360        self.show_legend
361    }
362
363    /// Returns the maximum display points.
364    pub fn max_display_points(&self) -> usize {
365        self.max_display_points
366    }
367
368    /// Returns the bar width.
369    pub fn bar_width(&self) -> u16 {
370        self.bar_width
371    }
372
373    /// Returns the bar gap.
374    pub fn bar_gap(&self) -> u16 {
375        self.bar_gap
376    }
377
378    /// Returns the number of series.
379    pub fn series_count(&self) -> usize {
380        self.series.len()
381    }
382
383    /// Returns true if there are no series.
384    pub fn is_empty(&self) -> bool {
385        self.series.is_empty()
386    }
387
388    /// Adds a series.
389    pub fn add_series(&mut self, series: DataSeries) {
390        self.series.push(series);
391    }
392
393    /// Clears all series.
394    pub fn clear_series(&mut self) {
395        self.series.clear();
396        self.active_series = 0;
397    }
398
399    /// Computes the global min value across all series.
400    pub fn global_min(&self) -> f64 {
401        self.series
402            .iter()
403            .map(|s| s.min())
404            .reduce(f64::min)
405            .unwrap_or(0.0)
406    }
407
408    /// Computes the global max value across all series.
409    pub fn global_max(&self) -> f64 {
410        self.series
411            .iter()
412            .map(|s| s.max())
413            .reduce(f64::max)
414            .unwrap_or(0.0)
415    }
416
417    // ---- Instance methods ----
418
419    /// Returns true if the component is focused.
420    pub fn is_focused(&self) -> bool {
421        self.focused
422    }
423
424    /// Sets the focus state.
425    pub fn set_focused(&mut self, focused: bool) {
426        self.focused = focused;
427    }
428
429    /// Returns true if the component is disabled.
430    pub fn is_disabled(&self) -> bool {
431        self.disabled
432    }
433
434    /// Sets the disabled state.
435    pub fn set_disabled(&mut self, disabled: bool) {
436        self.disabled = disabled;
437    }
438
439    /// Maps an input event to a chart message.
440    pub fn handle_event(&self, event: &Event) -> Option<ChartMessage> {
441        Chart::handle_event(self, event)
442    }
443
444    /// Dispatches an event, updating state and returning any output.
445    pub fn dispatch_event(&mut self, event: &Event) -> Option<ChartOutput> {
446        Chart::dispatch_event(self, event)
447    }
448
449    /// Updates the state with a message, returning any output.
450    pub fn update(&mut self, msg: ChartMessage) -> Option<ChartOutput> {
451        Chart::update(self, msg)
452    }
453}
454
455/// A chart component for data visualization.
456///
457/// Supports line charts (sparkline-style), vertical bar charts, and
458/// horizontal bar charts with multiple data series.
459///
460/// # Key Bindings
461///
462/// - `Tab` — Cycle to next series
463/// - `BackTab` — Cycle to previous series
464pub struct Chart(PhantomData<()>);
465
466impl Component for Chart {
467    type State = ChartState;
468    type Message = ChartMessage;
469    type Output = ChartOutput;
470
471    fn init() -> Self::State {
472        ChartState::default()
473    }
474
475    fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
476        if !state.focused || state.disabled {
477            return None;
478        }
479
480        let key = event.as_key()?;
481
482        match key.code {
483            KeyCode::Tab => Some(ChartMessage::NextSeries),
484            KeyCode::BackTab => Some(ChartMessage::PrevSeries),
485            _ => None,
486        }
487    }
488
489    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
490        if state.disabled || state.series.is_empty() {
491            return None;
492        }
493
494        let len = state.series.len();
495
496        match msg {
497            ChartMessage::NextSeries => {
498                state.active_series = (state.active_series + 1) % len;
499                Some(ChartOutput::ActiveSeriesChanged(state.active_series))
500            }
501            ChartMessage::PrevSeries => {
502                state.active_series = if state.active_series == 0 {
503                    len - 1
504                } else {
505                    state.active_series - 1
506                };
507                Some(ChartOutput::ActiveSeriesChanged(state.active_series))
508            }
509        }
510    }
511
512    fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
513        if area.height < 3 || area.width < 3 {
514            return;
515        }
516
517        let border_style = if state.disabled {
518            theme.disabled_style()
519        } else if state.focused {
520            theme.focused_border_style()
521        } else {
522            theme.border_style()
523        };
524
525        let mut block = Block::default()
526            .borders(Borders::ALL)
527            .border_style(border_style);
528
529        if let Some(ref title) = state.title {
530            block = block.title(title.as_str());
531        }
532
533        let inner = block.inner(area);
534        frame.render_widget(block, area);
535
536        if inner.height == 0 || inner.width == 0 || state.series.is_empty() {
537            return;
538        }
539
540        // Reserve space for legend and axis labels
541        let legend_height = if state.show_legend && state.series.len() > 1 {
542            1u16
543        } else {
544            0
545        };
546
547        let x_label_height = if state.x_label.is_some() { 1u16 } else { 0 };
548
549        let chart_area = if legend_height + x_label_height > 0 {
550            let chunks = Layout::default()
551                .direction(Direction::Vertical)
552                .constraints([
553                    Constraint::Min(1),
554                    Constraint::Length(legend_height),
555                    Constraint::Length(x_label_height),
556                ])
557                .split(inner);
558
559            // Render legend
560            if legend_height > 0 {
561                render_legend(state, frame, chunks[1]);
562            }
563
564            // Render x-axis label
565            if x_label_height > 0 {
566                if let Some(ref label) = state.x_label {
567                    let p = Paragraph::new(label.as_str())
568                        .alignment(Alignment::Center)
569                        .style(Style::default().fg(Color::DarkGray));
570                    frame.render_widget(p, chunks[2]);
571                }
572            }
573
574            chunks[0]
575        } else {
576            inner
577        };
578
579        match state.kind {
580            ChartKind::Line => render_line_chart(state, frame, chart_area, theme),
581            ChartKind::BarVertical => render_bar_chart(state, frame, chart_area, theme, false),
582            ChartKind::BarHorizontal => render_bar_chart(state, frame, chart_area, theme, true),
583        }
584    }
585}
586
587/// Renders the legend showing series labels and colors.
588fn render_legend(state: &ChartState, frame: &mut Frame, area: Rect) {
589    let spans: Vec<Span> = state
590        .series
591        .iter()
592        .enumerate()
593        .flat_map(|(i, s)| {
594            let marker = if i == state.active_series {
595                "●"
596            } else {
597                "○"
598            };
599            let separator = if i < state.series.len() - 1 { "  " } else { "" };
600            vec![Span::styled(
601                format!("{} {}{}", marker, s.label(), separator),
602                Style::default().fg(s.color()),
603            )]
604        })
605        .collect();
606
607    let line = Line::from(spans);
608    let paragraph = Paragraph::new(line).alignment(Alignment::Center);
609    frame.render_widget(paragraph, area);
610}
611
612/// Renders a line chart using sparkline.
613fn render_line_chart(state: &ChartState, frame: &mut Frame, area: Rect, theme: &Theme) {
614    if state.series.is_empty() {
615        return;
616    }
617
618    // Show y-axis labels on the left
619    let y_label_width = if state.y_label.is_some() { 8u16 } else { 0 };
620
621    let (y_area, chart_area) = if y_label_width > 0 {
622        let chunks = Layout::default()
623            .direction(Direction::Horizontal)
624            .constraints([Constraint::Length(y_label_width), Constraint::Min(1)])
625            .split(area);
626        (Some(chunks[0]), chunks[1])
627    } else {
628        (None, area)
629    };
630
631    // Render y-axis min/max labels
632    if let Some(y_area) = y_area {
633        let global_max = state.global_max();
634        let global_min = state.global_min();
635        let max_text = format!("{:.1}", global_max);
636        let min_text = format!("{:.1}", global_min);
637
638        if y_area.height >= 2 {
639            let p_max = Paragraph::new(max_text)
640                .style(Style::default().fg(Color::DarkGray))
641                .alignment(Alignment::Right);
642            frame.render_widget(p_max, Rect::new(y_area.x, y_area.y, y_area.width, 1));
643
644            let p_min = Paragraph::new(min_text)
645                .style(Style::default().fg(Color::DarkGray))
646                .alignment(Alignment::Right);
647            frame.render_widget(
648                p_min,
649                Rect::new(y_area.x, y_area.y + y_area.height - 1, y_area.width, 1),
650            );
651        }
652    }
653
654    // For multi-series, stack sparklines vertically
655    if state.series.len() == 1 || chart_area.height < 2 {
656        // Single series: full area sparkline
657        let series = &state.series[state.active_series];
658        let data = series_to_sparkline_data(series, state.max_display_points);
659        let style = if state.disabled {
660            theme.disabled_style()
661        } else {
662            Style::default().fg(series.color())
663        };
664        let sparkline = Sparkline::default().data(&data).style(style);
665        frame.render_widget(sparkline, chart_area);
666    } else {
667        // Multi-series: divide height
668        let count = state.series.len() as u16;
669        let constraints: Vec<Constraint> = (0..count)
670            .map(|_| Constraint::Ratio(1, count as u32))
671            .collect();
672
673        let areas = Layout::default()
674            .direction(Direction::Vertical)
675            .constraints(constraints)
676            .split(chart_area);
677
678        for (i, series) in state.series.iter().enumerate() {
679            if let Some(sparkline_area) = areas.get(i) {
680                let data = series_to_sparkline_data(series, state.max_display_points);
681                let style = if state.disabled {
682                    theme.disabled_style()
683                } else if i == state.active_series {
684                    Style::default()
685                        .fg(series.color())
686                        .add_modifier(Modifier::BOLD)
687                } else {
688                    Style::default().fg(series.color())
689                };
690                let sparkline = Sparkline::default().data(&data).style(style);
691                frame.render_widget(sparkline, *sparkline_area);
692            }
693        }
694    }
695}
696
697/// Converts a data series to sparkline-compatible u64 data.
698fn series_to_sparkline_data(series: &DataSeries, max_points: usize) -> Vec<u64> {
699    let values = if series.values.len() > max_points {
700        &series.values[series.values.len() - max_points..]
701    } else {
702        &series.values
703    };
704
705    if values.is_empty() {
706        return Vec::new();
707    }
708
709    let min = values.iter().copied().reduce(f64::min).unwrap_or(0.0);
710    let max = values.iter().copied().reduce(f64::max).unwrap_or(0.0);
711    let range = max - min;
712
713    if range == 0.0 {
714        return values.iter().map(|_| 50).collect();
715    }
716
717    values
718        .iter()
719        .map(|v| ((v - min) / range * 100.0) as u64)
720        .collect()
721}
722
723/// Renders a bar chart.
724fn render_bar_chart(
725    state: &ChartState,
726    frame: &mut Frame,
727    area: Rect,
728    theme: &Theme,
729    horizontal: bool,
730) {
731    if state.series.is_empty() {
732        return;
733    }
734
735    // For bar charts, use the first series (or active series)
736    let series = &state.series[state.active_series];
737    if series.is_empty() {
738        return;
739    }
740
741    let style = if state.disabled {
742        theme.disabled_style()
743    } else {
744        Style::default().fg(series.color())
745    };
746
747    // Create bars from the series values
748    let bars: Vec<Bar> = series
749        .values
750        .iter()
751        .enumerate()
752        .map(|(i, &v)| {
753            let label = format!("{}", i + 1);
754            Bar::default()
755                .value(v.max(0.0) as u64)
756                .label(Line::from(label))
757                .style(style)
758        })
759        .collect();
760
761    let group = BarGroup::default().bars(&bars);
762
763    let mut bar_chart = BarChart::default()
764        .data(group)
765        .bar_width(state.bar_width)
766        .bar_gap(state.bar_gap)
767        .bar_style(style);
768
769    if horizontal {
770        bar_chart = bar_chart.direction(Direction::Horizontal);
771    }
772
773    frame.render_widget(bar_chart, area);
774}
775
776#[cfg(test)]
777mod tests;