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;