Skip to main content

envision/component/chart/
mod.rs

1//! Chart components for data visualization.
2//!
3//! Provides line charts (braille rendering with shared axes), bar charts
4//! (horizontal/vertical), area charts, and scatter plots with data series,
5//! labels, colors, threshold lines, logarithmic scaling, smart tick labels,
6//! LTTB downsampling, and auto-scaling axes.
7//! State is stored in [`ChartState`] and updated via [`ChartMessage`].
8//!
9//! # Example
10//!
11//! ```rust
12//! use envision::component::{
13//!     Component, Chart, ChartState, ChartMessage, DataSeries, ChartKind,
14//! };
15//! use ratatui::style::Color;
16//!
17//! let series = DataSeries::new("Temperature", vec![20.0, 22.0, 25.0, 23.0])
18//!     .with_color(Color::Red);
19//! let mut state = ChartState::line(vec![series]);
20//! assert_eq!(state.series().len(), 1);
21//! assert_eq!(state.kind(), &ChartKind::Line);
22//! ```
23
24use std::marker::PhantomData;
25
26use ratatui::prelude::*;
27use ratatui::widgets::{Block, Borders, Paragraph};
28
29use super::{Component, EventContext, RenderContext};
30use crate::input::{Event, Key};
31
32mod annotations;
33mod builders;
34pub(crate) mod downsample;
35mod error_bands;
36pub(crate) mod format;
37mod grid;
38mod render;
39pub(crate) mod scale;
40mod series;
41mod state;
42pub(crate) mod ticks;
43
44pub use annotations::ChartAnnotation;
45pub use grid::ChartGrid;
46pub use scale::Scale;
47pub use series::{DEFAULT_PALETTE, chart_palette_color};
48
49/// Default color palette for auto-assigning colors to multi-series charts.
50/// A named data series with values and styling.
51#[derive(Clone, Debug, PartialEq)]
52#[cfg_attr(
53    feature = "serialization",
54    derive(serde::Serialize, serde::Deserialize)
55)]
56pub struct DataSeries {
57    /// The series label.
58    label: String,
59    /// The data values (Y-axis values).
60    values: Vec<f64>,
61    /// The display color.
62    color: Color,
63    /// Optional explicit X-axis values. When present, these are used instead of
64    /// sequential indices (0, 1, 2, ...). Useful for ROC curves, scatter plots
65    /// with non-uniform spacing, and parametric curves.
66    x_values: Option<Vec<f64>>,
67    upper_bound: Option<Vec<f64>>,
68    lower_bound: Option<Vec<f64>>,
69}
70
71// DataSeries methods are in series.rs
72
73/// The bar rendering mode for bar charts.
74///
75/// Controls how multiple series are displayed in bar charts:
76/// - `Single`: Only the active series is shown (default, backwards-compatible).
77/// - `Grouped`: All series are shown side-by-side at each position.
78/// - `Stacked`: All series are stacked vertically at each position.
79///
80/// # Example
81///
82/// ```rust
83/// use envision::component::BarMode;
84///
85/// let mode = BarMode::default();
86/// assert_eq!(mode, BarMode::Single);
87/// ```
88#[derive(Clone, Debug, Default, PartialEq, Eq)]
89#[cfg_attr(
90    feature = "serialization",
91    derive(serde::Serialize, serde::Deserialize)
92)]
93pub enum BarMode {
94    /// Render only the active series (default).
95    #[default]
96    Single,
97    /// Render all series side-by-side at each position.
98    Grouped,
99    /// Stack all series vertically at each position.
100    Stacked,
101}
102
103/// The kind of chart to display.
104#[derive(Clone, Debug, PartialEq, Eq)]
105#[cfg_attr(
106    feature = "serialization",
107    derive(serde::Serialize, serde::Deserialize)
108)]
109pub enum ChartKind {
110    /// A line chart (braille markers with shared axes).
111    Line,
112    /// A vertical bar chart.
113    BarVertical,
114    /// A horizontal bar chart.
115    BarHorizontal,
116    /// A filled line chart (area chart) using shared axes.
117    Area,
118    /// A scatter plot with individual data points.
119    Scatter,
120}
121
122/// A horizontal threshold/reference line rendered on area and scatter charts.
123///
124/// Threshold lines are drawn as horizontal lines spanning the full chart width
125/// at a specified y-value, useful for marking targets, SLOs, or limits.
126///
127/// # Example
128///
129/// ```rust
130/// use envision::component::ThresholdLine;
131/// use ratatui::style::Color;
132///
133/// let threshold = ThresholdLine::new(95.0, "SLO Target", Color::Yellow);
134/// assert_eq!(threshold.value, 95.0);
135/// assert_eq!(threshold.label, "SLO Target");
136/// assert_eq!(threshold.color, Color::Yellow);
137/// ```
138#[derive(Clone, Debug, PartialEq)]
139#[cfg_attr(
140    feature = "serialization",
141    derive(serde::Serialize, serde::Deserialize)
142)]
143pub struct ThresholdLine {
144    /// The y-value for this threshold.
145    pub value: f64,
146    /// Label for the threshold (e.g., "SLO target").
147    pub label: String,
148    /// Color for the threshold line.
149    pub color: Color,
150}
151
152impl ThresholdLine {
153    /// Creates a new threshold line.
154    ///
155    /// # Example
156    ///
157    /// ```rust
158    /// use envision::component::ThresholdLine;
159    /// use ratatui::style::Color;
160    ///
161    /// let t = ThresholdLine::new(100.0, "Max", Color::Red);
162    /// assert_eq!(t.value, 100.0);
163    /// assert_eq!(t.label, "Max");
164    /// ```
165    pub fn new(value: f64, label: impl Into<String>, color: Color) -> Self {
166        Self {
167            value,
168            label: label.into(),
169            color,
170        }
171    }
172}
173
174/// A vertical reference line rendered on line, area, and scatter charts.
175///
176/// Vertical lines are drawn as vertical lines spanning the full chart height
177/// at a specified x-value, useful for marking events, transitions, or epochs.
178///
179/// # Example
180///
181/// ```rust
182/// use envision::component::VerticalLine;
183/// use ratatui::style::Color;
184///
185/// let vline = VerticalLine::new(10000.0, "Grokking", Color::Yellow);
186/// assert_eq!(vline.x_value, 10000.0);
187/// assert_eq!(vline.label, "Grokking");
188/// ```
189#[derive(Clone, Debug, PartialEq)]
190#[cfg_attr(
191    feature = "serialization",
192    derive(serde::Serialize, serde::Deserialize)
193)]
194pub struct VerticalLine {
195    /// The x-value for this vertical line.
196    pub x_value: f64,
197    /// Label for the vertical line.
198    pub label: String,
199    /// Color for the vertical line.
200    pub color: Color,
201}
202
203impl VerticalLine {
204    /// Creates a new vertical reference line.
205    ///
206    /// # Example
207    ///
208    /// ```rust
209    /// use envision::component::VerticalLine;
210    /// use ratatui::style::Color;
211    ///
212    /// let vline = VerticalLine::new(500.0, "Deploy", Color::Green);
213    /// assert_eq!(vline.x_value, 500.0);
214    /// assert_eq!(vline.label, "Deploy");
215    /// assert_eq!(vline.color, Color::Green);
216    /// ```
217    pub fn new(x_value: f64, label: impl Into<String>, color: Color) -> Self {
218        Self {
219            x_value,
220            label: label.into(),
221            color,
222        }
223    }
224}
225
226/// Messages that can be sent to a Chart.
227#[derive(Clone, Debug, PartialEq)]
228#[cfg_attr(
229    feature = "serialization",
230    derive(serde::Serialize, serde::Deserialize)
231)]
232pub enum ChartMessage {
233    /// Cycle to the next series (for multi-series line charts).
234    NextSeries,
235    /// Cycle to the previous series.
236    PrevSeries,
237    /// Set threshold lines, replacing any existing ones.
238    SetThresholds(Vec<ThresholdLine>),
239    /// Add a single threshold line.
240    AddThreshold(ThresholdLine),
241    /// Set the manual Y-axis range. `None` values fall back to auto-scaling.
242    SetYRange(Option<f64>, Option<f64>),
243    /// Set the Y-axis scale (Linear, Log10, or SymLog).
244    SetYScale(Scale),
245    /// Set vertical reference lines, replacing any existing ones.
246    SetVerticalLines(Vec<VerticalLine>),
247    /// Add a single vertical reference line.
248    AddVerticalLine(VerticalLine),
249    /// Move the crosshair cursor left.
250    CursorLeft,
251    /// Move the crosshair cursor right.
252    CursorRight,
253    /// Move the crosshair cursor to the start.
254    CursorHome,
255    /// Move the crosshair cursor to the end.
256    CursorEnd,
257    /// Toggle the crosshair cursor visibility.
258    ToggleCrosshair,
259    /// Toggle grid line visibility.
260    ToggleGrid,
261}
262
263/// Output messages from a Chart.
264#[derive(Clone, Debug, PartialEq, Eq)]
265#[cfg_attr(
266    feature = "serialization",
267    derive(serde::Serialize, serde::Deserialize)
268)]
269pub enum ChartOutput {
270    /// The active series changed.
271    ActiveSeriesChanged(usize),
272    /// The crosshair cursor moved to a new position.
273    CursorMoved(usize),
274    /// The crosshair was toggled on or off.
275    CrosshairToggled(bool),
276    /// Grid lines were toggled on or off.
277    GridToggled(bool),
278}
279
280/// State for a Chart component.
281///
282/// Contains the data series, chart kind, and display options.
283#[derive(Clone, Debug, PartialEq)]
284#[cfg_attr(
285    feature = "serialization",
286    derive(serde::Serialize, serde::Deserialize)
287)]
288pub struct ChartState {
289    /// The data series to display.
290    pub(crate) series: Vec<DataSeries>,
291    /// The chart kind.
292    pub(crate) kind: ChartKind,
293    /// Index of the active (highlighted) series.
294    pub(crate) active_series: usize,
295    /// Optional title.
296    pub(crate) title: Option<String>,
297    /// X-axis label.
298    pub(crate) x_label: Option<String>,
299    /// Y-axis label.
300    pub(crate) y_label: Option<String>,
301    /// Whether to show the legend.
302    pub(crate) show_legend: bool,
303    /// Maximum data points to display (for line charts).
304    pub(crate) max_display_points: usize,
305    /// Bar width for bar charts.
306    pub(crate) bar_width: u16,
307    /// Bar gap for bar charts.
308    pub(crate) bar_gap: u16,
309    /// Horizontal threshold/reference lines.
310    pub(crate) thresholds: Vec<ThresholdLine>,
311    /// Manual Y-axis minimum (None = auto from data).
312    pub(crate) y_min: Option<f64>,
313    /// Manual Y-axis maximum (None = auto from data).
314    pub(crate) y_max: Option<f64>,
315    /// Y-axis scale transformation.
316    pub(crate) y_scale: Scale,
317    /// Vertical reference lines.
318    pub(crate) vertical_lines: Vec<VerticalLine>,
319    /// Cursor position (data index) for crosshair mode.
320    pub(crate) cursor_position: Option<usize>,
321    /// Whether to show the crosshair cursor.
322    pub(crate) show_crosshair: bool,
323    /// Whether to show grid lines at tick positions.
324    pub(crate) show_grid: bool,
325    /// Category labels for bar chart x-axis (e.g., ["Q1", "Q2", "Q3"]).
326    pub(crate) categories: Vec<String>,
327    /// Bar rendering mode (Single, Grouped, or Stacked).
328    pub(crate) bar_mode: BarMode,
329    /// Optional string labels for the X-axis of line, area, and scatter charts.
330    /// When present, these replace the numeric tick labels on the X-axis.
331    /// Useful for displaying dates, timestamps, or durations without a datetime dependency.
332    pub(crate) x_labels: Option<Vec<String>>,
333    /// Text annotations at specific data coordinates.
334    pub(crate) annotations: Vec<ChartAnnotation>,
335}
336
337impl Default for ChartState {
338    fn default() -> Self {
339        Self {
340            series: Vec::new(),
341            kind: ChartKind::Line,
342            active_series: 0,
343            title: None,
344            x_label: None,
345            y_label: None,
346            show_legend: true,
347            max_display_points: 500,
348            bar_width: 3,
349            bar_gap: 1,
350            thresholds: Vec::new(),
351            y_min: None,
352            y_max: None,
353            y_scale: Scale::default(),
354            vertical_lines: Vec::new(),
355            cursor_position: None,
356            show_crosshair: false,
357            show_grid: false,
358            categories: Vec::new(),
359            bar_mode: BarMode::default(),
360            x_labels: None,
361            annotations: Vec::new(),
362        }
363    }
364}
365
366/// A chart component for data visualization.
367///
368/// Supports line charts (braille rendering with shared axes), vertical bar
369/// charts, horizontal bar charts, area charts (filled line), and scatter plots
370/// with multiple data series, threshold lines, logarithmic scaling, smart tick
371/// labels, LTTB downsampling, and manual Y-axis scaling.
372///
373/// # Key Bindings
374///
375/// - `Tab` — Cycle to next series
376/// - `BackTab` — Cycle to previous series
377/// - `Left` / `h` — Move crosshair cursor left
378/// - `Right` / `l` — Move crosshair cursor right
379/// - `Home` — Move crosshair cursor to start
380/// - `End` — Move crosshair cursor to end
381/// - `c` — Toggle crosshair cursor visibility
382/// - `g` — Toggle grid line visibility
383pub struct Chart(PhantomData<()>);
384
385impl Component for Chart {
386    type State = ChartState;
387    type Message = ChartMessage;
388    type Output = ChartOutput;
389
390    fn init() -> Self::State {
391        ChartState::default()
392    }
393
394    fn handle_event(
395        _state: &Self::State,
396        event: &Event,
397        ctx: &EventContext,
398    ) -> Option<Self::Message> {
399        if !ctx.focused || ctx.disabled {
400            return None;
401        }
402
403        let key = event.as_key()?;
404
405        match key.code {
406            Key::Tab if key.modifiers.shift() => Some(ChartMessage::PrevSeries),
407            Key::Tab => Some(ChartMessage::NextSeries),
408            Key::Left | Key::Char('h') => Some(ChartMessage::CursorLeft),
409            Key::Right | Key::Char('l') => Some(ChartMessage::CursorRight),
410            Key::Home => Some(ChartMessage::CursorHome),
411            Key::End => Some(ChartMessage::CursorEnd),
412            Key::Char('c') => Some(ChartMessage::ToggleCrosshair),
413            Key::Char('g') => Some(ChartMessage::ToggleGrid),
414            _ => None,
415        }
416    }
417
418    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
419        match msg {
420            ChartMessage::SetThresholds(thresholds) => {
421                state.thresholds = thresholds;
422                None
423            }
424            ChartMessage::AddThreshold(threshold) => {
425                state.thresholds.push(threshold);
426                None
427            }
428            ChartMessage::SetYRange(min, max) => {
429                state.y_min = min;
430                state.y_max = max;
431                None
432            }
433            ChartMessage::SetYScale(scale) => {
434                state.y_scale = scale;
435                None
436            }
437            ChartMessage::SetVerticalLines(lines) => {
438                state.vertical_lines = lines;
439                None
440            }
441            ChartMessage::AddVerticalLine(line) => {
442                state.vertical_lines.push(line);
443                None
444            }
445            ChartMessage::ToggleCrosshair => {
446                state.show_crosshair = !state.show_crosshair;
447                if state.show_crosshair && state.cursor_position.is_none() {
448                    state.cursor_position = Some(0);
449                }
450                Some(ChartOutput::CrosshairToggled(state.show_crosshair))
451            }
452            ChartMessage::ToggleGrid => {
453                state.show_grid = !state.show_grid;
454                Some(ChartOutput::GridToggled(state.show_grid))
455            }
456            ChartMessage::CursorLeft
457            | ChartMessage::CursorRight
458            | ChartMessage::CursorHome
459            | ChartMessage::CursorEnd => {
460                let max_idx = state
461                    .series
462                    .iter()
463                    .map(|s| s.values().len())
464                    .max()
465                    .unwrap_or(1)
466                    .saturating_sub(1);
467
468                if max_idx == 0 {
469                    return None;
470                }
471
472                let current = state.cursor_position.unwrap_or(0);
473
474                let new_pos = match msg {
475                    ChartMessage::CursorLeft => current.saturating_sub(1),
476                    ChartMessage::CursorRight => (current + 1).min(max_idx),
477                    ChartMessage::CursorHome => 0,
478                    ChartMessage::CursorEnd => max_idx,
479                    _ => unreachable!(),
480                };
481
482                if new_pos != current || state.cursor_position.is_none() {
483                    state.cursor_position = Some(new_pos);
484                    if !state.show_crosshair {
485                        state.show_crosshair = true;
486                    }
487                    Some(ChartOutput::CursorMoved(new_pos))
488                } else {
489                    None
490                }
491            }
492            ChartMessage::NextSeries | ChartMessage::PrevSeries => {
493                if state.series.is_empty() {
494                    return None;
495                }
496
497                let len = state.series.len();
498
499                match msg {
500                    ChartMessage::NextSeries => {
501                        state.active_series = (state.active_series + 1) % len;
502                        Some(ChartOutput::ActiveSeriesChanged(state.active_series))
503                    }
504                    ChartMessage::PrevSeries => {
505                        state.active_series = if state.active_series == 0 {
506                            len - 1
507                        } else {
508                            state.active_series - 1
509                        };
510                        Some(ChartOutput::ActiveSeriesChanged(state.active_series))
511                    }
512                    _ => unreachable!(),
513                }
514            }
515        }
516    }
517
518    fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
519        if ctx.area.height < 3 || ctx.area.width < 3 {
520            return;
521        }
522
523        crate::annotation::with_registry(|reg| {
524            reg.register(
525                ctx.area,
526                crate::annotation::Annotation::container("chart")
527                    .with_focus(ctx.focused)
528                    .with_disabled(ctx.disabled),
529            );
530        });
531
532        let inner = if ctx.chrome_owned {
533            ctx.area
534        } else {
535            let border_style = if ctx.disabled {
536                ctx.theme.disabled_style()
537            } else if ctx.focused {
538                ctx.theme.focused_border_style()
539            } else {
540                ctx.theme.border_style()
541            };
542
543            let mut block = Block::default()
544                .borders(Borders::ALL)
545                .border_style(border_style);
546
547            if let Some(ref title) = state.title {
548                block = block.title(title.as_str());
549            }
550
551            let inner = block.inner(ctx.area);
552            ctx.frame.render_widget(block, ctx.area);
553            inner
554        };
555
556        if inner.height == 0 || inner.width == 0 || state.series.is_empty() {
557            return;
558        }
559
560        // Reserve space for title padding, legend, and axis labels
561        let title_padding = if state.title.is_some() { 1u16 } else { 0 };
562
563        let legend_entry_count =
564            state.series.len() + state.thresholds.len() + state.vertical_lines.len();
565        let legend_height = if state.show_legend && legend_entry_count > 1 {
566            1u16
567        } else {
568            0
569        };
570
571        let x_label_height = if state.x_label.is_some() { 1u16 } else { 0 };
572
573        let has_extras = title_padding + legend_height + x_label_height > 0;
574        let chart_area = if has_extras {
575            let chunks = Layout::default()
576                .direction(Direction::Vertical)
577                .constraints([
578                    Constraint::Length(title_padding),
579                    Constraint::Min(1),
580                    Constraint::Length(legend_height),
581                    Constraint::Length(x_label_height),
582                ])
583                .split(inner);
584
585            // Render legend
586            if legend_height > 0 {
587                render::render_legend(state, ctx.frame, chunks[2]);
588            }
589
590            // Render x-axis label
591            if x_label_height > 0 {
592                if let Some(ref label) = state.x_label {
593                    let p = Paragraph::new(label.as_str())
594                        .alignment(Alignment::Center)
595                        .style(Style::default().fg(Color::DarkGray));
596                    ctx.frame.render_widget(p, chunks[3]);
597                }
598            }
599
600            chunks[1]
601        } else {
602            inner
603        };
604
605        match state.kind {
606            ChartKind::Line | ChartKind::Area | ChartKind::Scatter => {
607                render::render_shared_axis_chart(
608                    state,
609                    ctx.frame,
610                    chart_area,
611                    ctx.theme,
612                    ctx.focused,
613                    ctx.disabled,
614                );
615
616                // Render crosshair value readout overlay
617                if state.show_crosshair {
618                    if let Some(pos) = state.cursor_position {
619                        render::render_crosshair_readout(state, ctx.frame, chart_area, pos);
620                    }
621                }
622            }
623            ChartKind::BarVertical => render::render_bar_chart(
624                state,
625                ctx.frame,
626                chart_area,
627                ctx.theme,
628                false,
629                ctx.focused,
630                ctx.disabled,
631            ),
632            ChartKind::BarHorizontal => render::render_bar_chart(
633                state,
634                ctx.frame,
635                chart_area,
636                ctx.theme,
637                true,
638                ctx.focused,
639                ctx.disabled,
640            ),
641        }
642    }
643}
644
645#[cfg(test)]
646mod annotation_tests;
647#[cfg(test)]
648mod area_fill_tests;
649#[cfg(test)]
650mod enhancement_tests;
651#[cfg(test)]
652mod error_band_tests;
653#[cfg(test)]
654mod render_tests;
655#[cfg(test)]
656mod snapshot_tests;
657#[cfg(test)]
658mod tests;
659#[cfg(test)]
660mod x_labels_tests;