envision/component/chart/
mod.rs1use 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#[derive(Clone, Debug, PartialEq)]
33pub struct DataSeries {
34 label: String,
36 values: Vec<f64>,
38 color: Color,
40}
41
42impl DataSeries {
43 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 pub fn with_color(mut self, color: Color) -> Self {
64 self.color = color;
65 self
66 }
67
68 pub fn label(&self) -> &str {
70 &self.label
71 }
72
73 pub fn values(&self) -> &[f64] {
75 &self.values
76 }
77
78 pub fn color(&self) -> Color {
80 self.color
81 }
82
83 pub fn push(&mut self, value: f64) {
85 self.values.push(value);
86 }
87
88 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 pub fn min(&self) -> f64 {
98 self.values.iter().copied().reduce(f64::min).unwrap_or(0.0)
99 }
100
101 pub fn max(&self) -> f64 {
103 self.values.iter().copied().reduce(f64::max).unwrap_or(0.0)
104 }
105
106 pub fn last(&self) -> Option<f64> {
108 self.values.last().copied()
109 }
110
111 pub fn len(&self) -> usize {
113 self.values.len()
114 }
115
116 pub fn is_empty(&self) -> bool {
118 self.values.is_empty()
119 }
120
121 pub fn clear(&mut self) {
123 self.values.clear();
124 }
125
126 pub fn set_label(&mut self, label: impl Into<String>) {
128 self.label = label.into();
129 }
130
131 pub fn set_color(&mut self, color: Color) {
133 self.color = color;
134 }
135}
136
137#[derive(Clone, Debug, PartialEq, Eq)]
139pub enum ChartKind {
140 Line,
142 BarVertical,
144 BarHorizontal,
146}
147
148#[derive(Clone, Debug, PartialEq, Eq)]
150pub enum ChartMessage {
151 NextSeries,
153 PrevSeries,
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
159pub enum ChartOutput {
160 ActiveSeriesChanged(usize),
162}
163
164#[derive(Clone, Debug, PartialEq)]
168pub struct ChartState {
169 series: Vec<DataSeries>,
171 kind: ChartKind,
173 active_series: usize,
175 title: Option<String>,
177 x_label: Option<String>,
179 y_label: Option<String>,
181 show_legend: bool,
183 max_display_points: usize,
185 bar_width: u16,
187 bar_gap: u16,
189 focused: bool,
191 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 pub fn line(series: Vec<DataSeries>) -> Self {
228 Self {
229 series,
230 kind: ChartKind::Line,
231 ..Default::default()
232 }
233 }
234
235 pub fn bar_vertical(series: Vec<DataSeries>) -> Self {
237 Self {
238 series,
239 kind: ChartKind::BarVertical,
240 ..Default::default()
241 }
242 }
243
244 pub fn bar_horizontal(series: Vec<DataSeries>) -> Self {
246 Self {
247 series,
248 kind: ChartKind::BarHorizontal,
249 ..Default::default()
250 }
251 }
252
253 pub fn with_title(mut self, title: impl Into<String>) -> Self {
255 self.title = Some(title.into());
256 self
257 }
258
259 pub fn with_x_label(mut self, label: impl Into<String>) -> Self {
261 self.x_label = Some(label.into());
262 self
263 }
264
265 pub fn with_y_label(mut self, label: impl Into<String>) -> Self {
267 self.y_label = Some(label.into());
268 self
269 }
270
271 pub fn with_legend(mut self, show: bool) -> Self {
273 self.show_legend = show;
274 self
275 }
276
277 pub fn with_max_display_points(mut self, max: usize) -> Self {
279 self.max_display_points = max;
280 self
281 }
282
283 pub fn with_bar_width(mut self, width: u16) -> Self {
285 self.bar_width = width.max(1);
286 self
287 }
288
289 pub fn with_bar_gap(mut self, gap: u16) -> Self {
291 self.bar_gap = gap;
292 self
293 }
294
295 pub fn with_disabled(mut self, disabled: bool) -> Self {
297 self.disabled = disabled;
298 self
299 }
300
301 pub fn series(&self) -> &[DataSeries] {
305 &self.series
306 }
307
308 pub fn series_mut(&mut self) -> &mut [DataSeries] {
310 &mut self.series
311 }
312
313 pub fn get_series(&self, index: usize) -> Option<&DataSeries> {
315 self.series.get(index)
316 }
317
318 pub fn get_series_mut(&mut self, index: usize) -> Option<&mut DataSeries> {
320 self.series.get_mut(index)
321 }
322
323 pub fn kind(&self) -> &ChartKind {
325 &self.kind
326 }
327
328 pub fn set_kind(&mut self, kind: ChartKind) {
330 self.kind = kind;
331 }
332
333 pub fn active_series(&self) -> usize {
335 self.active_series
336 }
337
338 pub fn title(&self) -> Option<&str> {
340 self.title.as_deref()
341 }
342
343 pub fn set_title(&mut self, title: Option<String>) {
345 self.title = title;
346 }
347
348 pub fn x_label(&self) -> Option<&str> {
350 self.x_label.as_deref()
351 }
352
353 pub fn y_label(&self) -> Option<&str> {
355 self.y_label.as_deref()
356 }
357
358 pub fn show_legend(&self) -> bool {
360 self.show_legend
361 }
362
363 pub fn max_display_points(&self) -> usize {
365 self.max_display_points
366 }
367
368 pub fn bar_width(&self) -> u16 {
370 self.bar_width
371 }
372
373 pub fn bar_gap(&self) -> u16 {
375 self.bar_gap
376 }
377
378 pub fn series_count(&self) -> usize {
380 self.series.len()
381 }
382
383 pub fn is_empty(&self) -> bool {
385 self.series.is_empty()
386 }
387
388 pub fn add_series(&mut self, series: DataSeries) {
390 self.series.push(series);
391 }
392
393 pub fn clear_series(&mut self) {
395 self.series.clear();
396 self.active_series = 0;
397 }
398
399 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 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 pub fn is_focused(&self) -> bool {
421 self.focused
422 }
423
424 pub fn set_focused(&mut self, focused: bool) {
426 self.focused = focused;
427 }
428
429 pub fn is_disabled(&self) -> bool {
431 self.disabled
432 }
433
434 pub fn set_disabled(&mut self, disabled: bool) {
436 self.disabled = disabled;
437 }
438
439 pub fn handle_event(&self, event: &Event) -> Option<ChartMessage> {
441 Chart::handle_event(self, event)
442 }
443
444 pub fn dispatch_event(&mut self, event: &Event) -> Option<ChartOutput> {
446 Chart::dispatch_event(self, event)
447 }
448
449 pub fn update(&mut self, msg: ChartMessage) -> Option<ChartOutput> {
451 Chart::update(self, msg)
452 }
453}
454
455pub 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 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 if legend_height > 0 {
561 render_legend(state, frame, chunks[1]);
562 }
563
564 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
587fn 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
612fn render_line_chart(state: &ChartState, frame: &mut Frame, area: Rect, theme: &Theme) {
614 if state.series.is_empty() {
615 return;
616 }
617
618 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 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 if state.series.len() == 1 || chart_area.height < 2 {
656 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 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
697fn 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
723fn 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 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 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;