1use ratatui::{
4 layout::{Constraint, Direction, Layout, Rect},
5 style::{Modifier, Style},
6 symbols,
7 text::{Line, Span},
8 widgets::{
9 Axis, Block, BorderType, Borders, Chart, Dataset, GraphType, List, ListItem, Paragraph,
10 StatefulWidget, Tabs, Widget,
11 },
12};
13
14use crate::chart_data::{
15 format_axis_label, format_x_axis_label, BoxPlotData, HeatmapData, HistogramData, KdeData,
16 XAxisTemporalKind,
17};
18use crate::chart_modal::{ChartFocus, ChartKind, ChartModal, ChartType};
19use crate::config::Theme;
20use crate::widgets::radio_block::RadioBlock;
21use std::collections::HashSet;
22
23const SIDEBAR_WIDTH: u16 = 42;
24const LABEL_WIDTH: u16 = 20;
25const TAB_HEIGHT: u16 = 3;
26const HEATMAP_TITLE_HEIGHT: u16 = 1;
27const HEATMAP_X_LABEL_HEIGHT: u16 = 2;
28
29pub enum ChartRenderData<'a> {
30 XY {
31 series: Option<&'a Vec<Vec<(f64, f64)>>>,
32 x_axis_kind: XAxisTemporalKind,
33 x_bounds: Option<(f64, f64)>,
34 },
35 Histogram {
36 data: Option<&'a HistogramData>,
37 },
38 BoxPlot {
39 data: Option<&'a BoxPlotData>,
40 },
41 Kde {
42 data: Option<&'a KdeData>,
43 },
44 Heatmap {
45 data: Option<&'a HeatmapData>,
46 },
47}
48
49fn render_axis_list(
52 area: Rect,
53 buf: &mut ratatui::buffer::Buffer,
54 list_state: &mut ratatui::widgets::ListState,
55 display_items: &[String],
56 selected_set: &HashSet<String>,
57 is_focused: bool,
58 theme: &Theme,
59) {
60 let active_color = theme.get("modal_border_active");
61 let text_primary = theme.get("text_primary");
62
63 let list_items: Vec<ListItem> = display_items
64 .iter()
65 .map(|name| {
66 let style = if selected_set.contains(name) {
67 Style::default().fg(active_color)
68 } else {
69 Style::default().fg(text_primary)
70 };
71 ListItem::new(Line::from(Span::styled(name.as_str(), style)))
72 })
73 .collect();
74
75 let list = List::new(list_items).highlight_style(if is_focused {
76 Style::default().add_modifier(Modifier::REVERSED)
77 } else {
78 Style::default()
79 });
80 StatefulWidget::render(list, area, buf, list_state);
81}
82
83#[allow(clippy::too_many_arguments)]
84fn render_filter_group(
85 area: Rect,
86 buf: &mut ratatui::buffer::Buffer,
87 input: &mut crate::widgets::text_input::TextInput,
88 list_state: &mut ratatui::widgets::ListState,
89 display_items: &[String],
90 selected_set: &HashSet<String>,
91 is_input_focused: bool,
92 is_list_focused: bool,
93 theme: &Theme,
94 title: &str,
95) {
96 let border_color = theme.get("modal_border");
97 let active_color = theme.get("modal_border_active");
98 let group_border = if is_input_focused || is_list_focused {
99 active_color
100 } else {
101 border_color
102 };
103 let group_block = Block::default()
104 .borders(Borders::ALL)
105 .border_type(BorderType::Rounded)
106 .border_style(Style::default().fg(group_border))
107 .title(title);
108 let group_inner = group_block.inner(area);
109 group_block.render(area, buf);
110
111 let inner = Layout::default()
112 .direction(Direction::Vertical)
113 .constraints([
114 Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), ])
118 .split(group_inner);
119
120 input.set_focused(is_input_focused);
121 input.render(inner[0], buf);
122
123 let divider = Block::default()
124 .borders(Borders::TOP)
125 .border_type(BorderType::Rounded)
126 .border_style(Style::default().fg(group_border));
127 divider.render(inner[1], buf);
128
129 render_axis_list(
130 inner[2],
131 buf,
132 list_state,
133 display_items,
134 selected_set,
135 is_list_focused,
136 theme,
137 );
138}
139
140fn render_number_option(
141 area: Rect,
142 buf: &mut ratatui::buffer::Buffer,
143 label: &str,
144 value: &str,
145 is_focused: bool,
146 theme: &Theme,
147) {
148 let border_color = theme.get("modal_border");
149 let active_color = theme.get("modal_border_active");
150 let style = if is_focused {
151 Style::default().fg(active_color)
152 } else {
153 Style::default().fg(border_color)
154 };
155 let row = Layout::default()
156 .direction(Direction::Horizontal)
157 .constraints([Constraint::Length(LABEL_WIDTH), Constraint::Min(1)])
158 .split(area);
159 Paragraph::new(label).style(style).render(row[0], buf);
160 Paragraph::new(value).style(style).render(row[1], buf);
161}
162
163pub fn render_chart_view(
166 area: Rect,
167 buf: &mut ratatui::buffer::Buffer,
168 modal: &mut ChartModal,
169 theme: &Theme,
170 render_data: ChartRenderData<'_>,
171) {
172 modal.clamp_list_selections_to_filtered();
173
174 let border_color = theme.get("modal_border");
175 let active_color = theme.get("modal_border_active");
176 let text_primary = theme.get("text_primary");
177 let text_secondary = theme.get("text_secondary");
178
179 let layout = Layout::default()
180 .direction(Direction::Vertical)
181 .constraints([Constraint::Length(TAB_HEIGHT), Constraint::Fill(1)])
182 .split(area);
183
184 let tab_titles: Vec<Line> = ChartKind::ALL
185 .iter()
186 .map(|k| Line::from(Span::raw(k.as_str())))
187 .collect();
188 let selected_tab = ChartKind::ALL
189 .iter()
190 .position(|&k| k == modal.chart_kind)
191 .unwrap_or(0);
192 let tab_bar_focused = modal.focus == ChartFocus::TabBar;
193 let tab_block = Block::default()
194 .borders(Borders::ALL)
195 .border_type(BorderType::Rounded)
196 .border_style(Style::default().fg(if tab_bar_focused {
197 active_color
198 } else {
199 border_color
200 }))
201 .title(" Chart ");
202 let tab_highlight = if tab_bar_focused {
203 Style::default()
204 .fg(active_color)
205 .add_modifier(Modifier::BOLD)
206 } else {
207 Style::default().fg(active_color)
208 };
209 let tabs = Tabs::new(tab_titles)
210 .block(tab_block)
211 .select(selected_tab)
212 .style(Style::default().fg(border_color))
213 .highlight_style(tab_highlight);
214 tabs.render(layout[0], buf);
215
216 let main_layout = Layout::default()
217 .direction(Direction::Horizontal)
218 .constraints([Constraint::Length(SIDEBAR_WIDTH), Constraint::Fill(1)])
219 .split(layout[1]);
220
221 let sidebar_block = Block::default()
223 .borders(Borders::ALL)
224 .border_type(BorderType::Rounded)
225 .border_style(Style::default().fg(border_color))
226 .title(" Options ");
227 let sidebar_inner = sidebar_block.inner(main_layout[0]);
228 sidebar_block.render(main_layout[0], buf);
229
230 let focus = modal.focus;
231
232 match modal.chart_kind {
233 ChartKind::XY => {
234 let x_display = modal.x_display_list();
235 let y_display = modal.y_display_list();
236 let x_selected_set: HashSet<String> = modal.x_column.iter().cloned().collect();
237 let y_selected_set: HashSet<String> = modal.y_columns.iter().cloned().collect();
238
239 let sidebar_content = Layout::default()
240 .direction(Direction::Vertical)
241 .constraints([
242 Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
254 .split(sidebar_inner);
255
256 let is_type_focused = focus == ChartFocus::ChartType;
257 let type_labels: [&str; 3] = ["Line", "Scatter", "Bar"];
258 let type_selected = ChartType::ALL
259 .iter()
260 .position(|&t| t == modal.chart_type)
261 .unwrap_or(0);
262 RadioBlock::new(
263 " Plot style ",
264 &type_labels,
265 type_selected,
266 is_type_focused,
267 3,
268 border_color,
269 active_color,
270 )
271 .render(sidebar_content[0], buf);
272
273 Paragraph::new("X axis:")
274 .style(Style::default().fg(text_primary))
275 .render(sidebar_content[2], buf);
276
277 render_filter_group(
278 sidebar_content[3],
279 buf,
280 &mut modal.x_input,
281 &mut modal.x_list_state,
282 &x_display,
283 &x_selected_set,
284 focus == ChartFocus::XInput,
285 focus == ChartFocus::XList,
286 theme,
287 " Filter Columns ",
288 );
289
290 Paragraph::new("Y axis:")
291 .style(Style::default().fg(text_primary))
292 .render(sidebar_content[5], buf);
293
294 render_filter_group(
295 sidebar_content[6],
296 buf,
297 &mut modal.y_input,
298 &mut modal.y_list_state,
299 &y_display,
300 &y_selected_set,
301 focus == ChartFocus::YInput,
302 focus == ChartFocus::YList,
303 theme,
304 " Filter Columns ",
305 );
306
307 let y0_row = Layout::default()
308 .direction(Direction::Horizontal)
309 .constraints([
310 Constraint::Length(LABEL_WIDTH),
311 Constraint::Length(2),
312 Constraint::Min(1),
313 ])
314 .split(sidebar_content[7]);
315 let is_y0_focused = focus == ChartFocus::YStartsAtZero;
316 let y0_label_style = if is_y0_focused {
317 Style::default().fg(active_color)
318 } else {
319 Style::default().fg(border_color)
320 };
321 Paragraph::new("Start y axis at 0:")
322 .style(y0_label_style)
323 .render(y0_row[0], buf);
324 let y0_marker = if modal.y_starts_at_zero { "☑" } else { "☐" };
325 let y0_check_style = if is_y0_focused {
326 Style::default().fg(active_color)
327 } else {
328 Style::default().fg(border_color)
329 };
330 Paragraph::new(Line::from(Span::styled(y0_marker, y0_check_style)))
331 .render(y0_row[1], buf);
332
333 let log_row = Layout::default()
334 .direction(Direction::Horizontal)
335 .constraints([
336 Constraint::Length(LABEL_WIDTH),
337 Constraint::Length(2),
338 Constraint::Min(1),
339 ])
340 .split(sidebar_content[8]);
341 let is_log_focused = focus == ChartFocus::LogScale;
342 let log_label_style = if is_log_focused {
343 Style::default().fg(active_color)
344 } else {
345 Style::default().fg(border_color)
346 };
347 Paragraph::new("Log Scale:")
348 .style(log_label_style)
349 .render(log_row[0], buf);
350 let log_marker = if modal.log_scale { "☑" } else { "☐" };
351 let log_check_style = if is_log_focused {
352 Style::default().fg(active_color)
353 } else {
354 Style::default().fg(border_color)
355 };
356 Paragraph::new(Line::from(Span::styled(log_marker, log_check_style)))
357 .render(log_row[1], buf);
358
359 let legend_row = Layout::default()
360 .direction(Direction::Horizontal)
361 .constraints([
362 Constraint::Length(LABEL_WIDTH),
363 Constraint::Length(2),
364 Constraint::Min(1),
365 ])
366 .split(sidebar_content[9]);
367 let is_legend_focused = focus == ChartFocus::ShowLegend;
368 let legend_label_style = if is_legend_focused {
369 Style::default().fg(active_color)
370 } else {
371 Style::default().fg(border_color)
372 };
373 Paragraph::new("Legend:")
374 .style(legend_label_style)
375 .render(legend_row[0], buf);
376 let legend_marker = if modal.show_legend { "☑" } else { "☐" };
377 let legend_check_style = if is_legend_focused {
378 Style::default().fg(active_color)
379 } else {
380 Style::default().fg(border_color)
381 };
382 Paragraph::new(Line::from(Span::styled(legend_marker, legend_check_style)))
383 .render(legend_row[1], buf);
384
385 render_number_option(
386 sidebar_content[10],
387 buf,
388 "Limit Rows:",
389 &modal.row_limit_display(),
390 focus == ChartFocus::LimitRows,
391 theme,
392 );
393 }
394 ChartKind::Histogram => {
395 let hist_display = modal.hist_display_list();
396 let hist_selected_set: HashSet<String> = modal.hist_column.iter().cloned().collect();
397 let sidebar_content = Layout::default()
398 .direction(Direction::Vertical)
399 .constraints([
400 Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), ])
405 .split(sidebar_inner);
406 Paragraph::new("Value column:")
407 .style(Style::default().fg(text_primary))
408 .render(sidebar_content[0], buf);
409 render_filter_group(
410 sidebar_content[1],
411 buf,
412 &mut modal.hist_input,
413 &mut modal.hist_list_state,
414 &hist_display,
415 &hist_selected_set,
416 focus == ChartFocus::HistInput,
417 focus == ChartFocus::HistList,
418 theme,
419 " Filter Columns ",
420 );
421 render_number_option(
422 sidebar_content[2],
423 buf,
424 "Bins:",
425 &format!("{}", modal.hist_bins),
426 focus == ChartFocus::HistBins,
427 theme,
428 );
429 render_number_option(
430 sidebar_content[3],
431 buf,
432 "Limit Rows:",
433 &modal.row_limit_display(),
434 focus == ChartFocus::LimitRows,
435 theme,
436 );
437 }
438 ChartKind::BoxPlot => {
439 let box_display = modal.box_display_list();
440 let box_selected_set: HashSet<String> = modal.box_column.iter().cloned().collect();
441 let sidebar_content = Layout::default()
442 .direction(Direction::Vertical)
443 .constraints([
444 Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), ])
448 .split(sidebar_inner);
449 Paragraph::new("Value column:")
450 .style(Style::default().fg(text_primary))
451 .render(sidebar_content[0], buf);
452 render_filter_group(
453 sidebar_content[1],
454 buf,
455 &mut modal.box_input,
456 &mut modal.box_list_state,
457 &box_display,
458 &box_selected_set,
459 focus == ChartFocus::BoxInput,
460 focus == ChartFocus::BoxList,
461 theme,
462 " Filter Columns ",
463 );
464 render_number_option(
465 sidebar_content[2],
466 buf,
467 "Limit Rows:",
468 &modal.row_limit_display(),
469 focus == ChartFocus::LimitRows,
470 theme,
471 );
472 }
473 ChartKind::Kde => {
474 let kde_display = modal.kde_display_list();
475 let kde_selected_set: HashSet<String> = modal.kde_column.iter().cloned().collect();
476 let sidebar_content = Layout::default()
477 .direction(Direction::Vertical)
478 .constraints([
479 Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), ])
484 .split(sidebar_inner);
485 Paragraph::new("Value column:")
486 .style(Style::default().fg(text_primary))
487 .render(sidebar_content[0], buf);
488 render_filter_group(
489 sidebar_content[1],
490 buf,
491 &mut modal.kde_input,
492 &mut modal.kde_list_state,
493 &kde_display,
494 &kde_selected_set,
495 focus == ChartFocus::KdeInput,
496 focus == ChartFocus::KdeList,
497 theme,
498 " Filter Columns ",
499 );
500 render_number_option(
501 sidebar_content[2],
502 buf,
503 "Bandwidth:",
504 &format!("x{:.1}", modal.kde_bandwidth_factor),
505 focus == ChartFocus::KdeBandwidth,
506 theme,
507 );
508 render_number_option(
509 sidebar_content[3],
510 buf,
511 "Limit Rows:",
512 &modal.row_limit_display(),
513 focus == ChartFocus::LimitRows,
514 theme,
515 );
516 }
517 ChartKind::Heatmap => {
518 let x_display = modal.heatmap_x_display_list();
519 let y_display = modal.heatmap_y_display_list();
520 let x_selected_set: HashSet<String> = modal.heatmap_x_column.iter().cloned().collect();
521 let y_selected_set: HashSet<String> = modal.heatmap_y_column.iter().cloned().collect();
522 let sidebar_content = Layout::default()
523 .direction(Direction::Vertical)
524 .constraints([
525 Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), Constraint::Min(4), Constraint::Length(1), Constraint::Length(1), ])
533 .split(sidebar_inner);
534 Paragraph::new("X axis:")
535 .style(Style::default().fg(text_primary))
536 .render(sidebar_content[0], buf);
537 render_filter_group(
538 sidebar_content[1],
539 buf,
540 &mut modal.heatmap_x_input,
541 &mut modal.heatmap_x_list_state,
542 &x_display,
543 &x_selected_set,
544 focus == ChartFocus::HeatmapXInput,
545 focus == ChartFocus::HeatmapXList,
546 theme,
547 " Filter Columns ",
548 );
549 Paragraph::new("Y axis:")
550 .style(Style::default().fg(text_primary))
551 .render(sidebar_content[3], buf);
552 render_filter_group(
553 sidebar_content[4],
554 buf,
555 &mut modal.heatmap_y_input,
556 &mut modal.heatmap_y_list_state,
557 &y_display,
558 &y_selected_set,
559 focus == ChartFocus::HeatmapYInput,
560 focus == ChartFocus::HeatmapYList,
561 theme,
562 " Filter Columns ",
563 );
564 render_number_option(
565 sidebar_content[5],
566 buf,
567 "Bins:",
568 &format!("{}", modal.heatmap_bins),
569 focus == ChartFocus::HeatmapBins,
570 theme,
571 );
572 render_number_option(
573 sidebar_content[6],
574 buf,
575 "Limit Rows:",
576 &modal.row_limit_display(),
577 focus == ChartFocus::LimitRows,
578 theme,
579 );
580 }
581 }
582
583 let chart_inner = main_layout[1];
584 match render_data {
585 ChartRenderData::XY {
586 series,
587 x_axis_kind,
588 x_bounds,
589 } => render_xy_chart(
590 chart_inner,
591 buf,
592 modal,
593 theme,
594 series,
595 x_axis_kind,
596 x_bounds,
597 text_secondary,
598 ),
599 ChartRenderData::Histogram { data } => {
600 render_histogram_chart(chart_inner, buf, theme, data, text_secondary)
601 }
602 ChartRenderData::BoxPlot { data } => {
603 render_box_plot_chart(chart_inner, buf, theme, data, text_secondary)
604 }
605 ChartRenderData::Kde { data } => {
606 render_kde_chart(chart_inner, buf, modal, theme, data, text_secondary)
607 }
608 ChartRenderData::Heatmap { data } => {
609 render_heatmap_chart(chart_inner, buf, theme, data, text_secondary)
610 }
611 }
612}
613
614#[allow(clippy::too_many_arguments)]
615fn render_xy_chart(
616 area: Rect,
617 buf: &mut ratatui::buffer::Buffer,
618 modal: &ChartModal,
619 theme: &Theme,
620 chart_data: Option<&Vec<Vec<(f64, f64)>>>,
621 x_axis_kind: XAxisTemporalKind,
622 x_bounds: Option<(f64, f64)>,
623 text_secondary: ratatui::style::Color,
624) {
625 let chart_type = modal.chart_type;
626 let y_starts_at_zero = modal.y_starts_at_zero;
627 let log_scale = modal.log_scale;
628 let show_legend = modal.show_legend;
629
630 let has_x_selected = modal.effective_x_column().is_some();
631 let has_data = chart_data
632 .map(|d| d.iter().any(|s| !s.is_empty()))
633 .unwrap_or(false);
634
635 if has_x_selected && !has_data {
636 let x_name = modal
637 .effective_x_column()
638 .map(|s| s.as_str())
639 .unwrap_or("X");
640 let y_names: String = modal.effective_y_columns().join(", ");
641 let axis_label_style = Style::default().fg(theme.get("text_primary"));
642 const PLACEHOLDER_MIN: f64 = 0.0;
643 const PLACEHOLDER_MAX: f64 = 1.0;
644 let (x_min, x_max) = x_bounds.unwrap_or((PLACEHOLDER_MIN, PLACEHOLDER_MAX));
645 let format_x = |v: f64| format_x_axis_label(v, x_axis_kind);
646 let x_labels = vec![
647 Span::styled(format_x(x_min), axis_label_style),
648 Span::styled(format_x((x_min + x_max) / 2.0), axis_label_style),
649 Span::styled(format_x(x_max), axis_label_style),
650 ];
651 let y_labels = vec![
652 Span::styled(format_axis_label(PLACEHOLDER_MIN), axis_label_style),
653 Span::styled(
654 format_axis_label((PLACEHOLDER_MIN + PLACEHOLDER_MAX) / 2.0),
655 axis_label_style,
656 ),
657 Span::styled(format_axis_label(PLACEHOLDER_MAX), axis_label_style),
658 ];
659 let x_axis = Axis::default()
660 .title(x_name)
661 .bounds([x_min, x_max])
662 .style(Style::default().fg(theme.get("text_primary")))
663 .labels(x_labels);
664 let y_axis = Axis::default()
665 .title(y_names)
666 .bounds([PLACEHOLDER_MIN, PLACEHOLDER_MAX])
667 .style(Style::default().fg(theme.get("text_primary")))
668 .labels(y_labels);
669 let empty_dataset = Dataset::default()
670 .name("")
671 .data(&[])
672 .graph_type(match chart_type {
673 ChartType::Line => GraphType::Line,
674 ChartType::Scatter => GraphType::Scatter,
675 ChartType::Bar => GraphType::Bar,
676 });
677 let mut chart = Chart::new(vec![empty_dataset])
678 .x_axis(x_axis)
679 .y_axis(y_axis);
680 if show_legend {
681 chart = chart.legend_position(Some(ratatui::widgets::LegendPosition::TopRight));
682 } else {
683 chart = chart.legend_position(None);
684 }
685 chart.render(area, buf);
686 return;
687 }
688
689 if has_data {
690 if let Some(data) = chart_data {
691 let y_columns = modal.effective_y_columns();
692 let graph_type = match chart_type {
693 ChartType::Line => GraphType::Line,
694 ChartType::Scatter => GraphType::Scatter,
695 ChartType::Bar => GraphType::Bar,
696 };
697 let marker = match chart_type {
698 ChartType::Line => symbols::Marker::Braille,
699 ChartType::Scatter => symbols::Marker::Dot,
700 ChartType::Bar => symbols::Marker::HalfBlock,
701 };
702
703 let series_colors = [
704 "chart_series_color_1",
705 "chart_series_color_2",
706 "chart_series_color_3",
707 "chart_series_color_4",
708 "chart_series_color_5",
709 "chart_series_color_6",
710 "chart_series_color_7",
711 ];
712
713 let mut all_x_min = f64::INFINITY;
714 let mut all_x_max = f64::NEG_INFINITY;
715 let mut all_y_min = f64::INFINITY;
716 let mut all_y_max = f64::NEG_INFINITY;
717
718 let names_and_points: Vec<(&str, &[(f64, f64)])> = data
720 .iter()
721 .zip(y_columns.iter())
722 .filter_map(|(points, name)| {
723 if points.is_empty() {
724 return None;
725 }
726 Some((name.as_str(), points.as_slice()))
727 })
728 .collect();
729
730 for (_, points) in &names_and_points {
731 let (x_min, x_max) = points
732 .iter()
733 .map(|&(x, _)| x)
734 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| {
735 (a.min(x), b.max(x))
736 });
737 let (y_min, y_max) = points
738 .iter()
739 .map(|&(_, y)| y)
740 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), y| {
741 (a.min(y), b.max(y))
742 });
743 all_x_min = all_x_min.min(x_min);
744 all_x_max = all_x_max.max(x_max);
745 all_y_min = all_y_min.min(y_min);
746 all_y_max = all_y_max.max(y_max);
747 }
748
749 let datasets: Vec<Dataset> = names_and_points
750 .iter()
751 .enumerate()
752 .map(|(i, (name, points))| {
753 let color_key = series_colors
754 .get(i)
755 .copied()
756 .unwrap_or("primary_chart_series_color");
757 let style = Style::default().fg(theme.get(color_key));
758 Dataset::default()
759 .name(*name)
760 .marker(marker)
761 .graph_type(graph_type)
762 .style(style)
763 .data(points)
764 })
765 .collect();
766
767 if datasets.is_empty() {
768 Paragraph::new("No valid data points")
769 .style(Style::default().fg(text_secondary))
770 .centered()
771 .render(area, buf);
772 return;
773 }
774
775 let y_min_bounds = if chart_type == ChartType::Bar {
776 0.0_f64.min(all_y_min)
777 } else if y_starts_at_zero {
778 0.0
779 } else {
780 all_y_min
781 };
782 let y_max_bounds = if all_y_max > y_min_bounds {
783 all_y_max
784 } else {
785 y_min_bounds + 1.0
786 };
787 let x_min_bounds = if all_x_max > all_x_min {
788 all_x_min
789 } else {
790 all_x_min - 0.5
791 };
792 let x_max_bounds = if all_x_max > all_x_min {
793 all_x_max
794 } else {
795 all_x_min + 0.5
796 };
797
798 let axis_label_style = Style::default().fg(theme.get("text_primary"));
799 let format_x = |v: f64| format_x_axis_label(v, x_axis_kind);
800 let x_labels = vec![
801 Span::styled(format_x(x_min_bounds), axis_label_style),
802 Span::styled(
803 format_x((x_min_bounds + x_max_bounds) / 2.0),
804 axis_label_style,
805 ),
806 Span::styled(format_x(x_max_bounds), axis_label_style),
807 ];
808 let format_y_label = |log_v: f64| {
809 let v = if log_scale { log_v.exp_m1() } else { log_v };
810 format_axis_label(v)
811 };
812 let y_labels = vec![
813 Span::styled(format_y_label(y_min_bounds), axis_label_style),
814 Span::styled(
815 format_y_label((y_min_bounds + y_max_bounds) / 2.0),
816 axis_label_style,
817 ),
818 Span::styled(format_y_label(y_max_bounds), axis_label_style),
819 ];
820
821 let x_axis_title = modal.effective_x_column().map(|s| s.as_str()).unwrap_or("");
822 let y_axis_title = y_columns.join(", ");
823 let x_axis = Axis::default()
824 .title(x_axis_title)
825 .bounds([x_min_bounds, x_max_bounds])
826 .style(Style::default().fg(theme.get("text_primary")))
827 .labels(x_labels);
828 let y_axis = Axis::default()
829 .title(y_axis_title)
830 .bounds([y_min_bounds, y_max_bounds])
831 .style(Style::default().fg(theme.get("text_primary")))
832 .labels(y_labels);
833
834 let mut chart = Chart::new(datasets).x_axis(x_axis).y_axis(y_axis);
835 if show_legend {
836 chart = chart.legend_position(Some(ratatui::widgets::LegendPosition::TopRight));
837 } else {
838 chart = chart.legend_position(None);
839 }
840 chart.render(area, buf);
841 }
842 } else {
843 Paragraph::new("Select X and Y columns in sidebar — Tab to change focus")
844 .style(Style::default().fg(text_secondary))
845 .centered()
846 .render(area, buf);
847 }
848}
849
850fn render_histogram_chart(
851 area: Rect,
852 buf: &mut ratatui::buffer::Buffer,
853 theme: &Theme,
854 data: Option<&HistogramData>,
855 text_secondary: ratatui::style::Color,
856) {
857 let Some(data) = data else {
858 Paragraph::new("Select a column for histogram")
859 .style(Style::default().fg(text_secondary))
860 .centered()
861 .render(area, buf);
862 return;
863 };
864 if data.bins.is_empty() {
865 Paragraph::new("No data for histogram")
866 .style(Style::default().fg(text_secondary))
867 .centered()
868 .render(area, buf);
869 return;
870 }
871
872 let points: Vec<(f64, f64)> = data.bins.iter().map(|b| (b.center, b.count)).collect();
873 let series = [points];
874
875 let x_min_bounds = data.x_min;
876 let x_max_bounds = if data.x_max > data.x_min {
877 data.x_max
878 } else {
879 data.x_min + 1.0
880 };
881 let y_min_bounds = 0.0;
882 let y_max_bounds = if data.max_count > 0.0 {
883 data.max_count
884 } else {
885 1.0
886 };
887
888 let axis_label_style = Style::default().fg(theme.get("text_primary"));
889 let x_labels = vec![
890 Span::styled(format_axis_label(x_min_bounds), axis_label_style),
891 Span::styled(
892 format_axis_label((x_min_bounds + x_max_bounds) / 2.0),
893 axis_label_style,
894 ),
895 Span::styled(format_axis_label(x_max_bounds), axis_label_style),
896 ];
897 let y_labels = vec![
898 Span::styled(format_axis_label(y_min_bounds), axis_label_style),
899 Span::styled(
900 format_axis_label((y_min_bounds + y_max_bounds) / 2.0),
901 axis_label_style,
902 ),
903 Span::styled(format_axis_label(y_max_bounds), axis_label_style),
904 ];
905
906 let x_axis = Axis::default()
907 .title(data.column.as_str())
908 .bounds([x_min_bounds, x_max_bounds])
909 .style(Style::default().fg(theme.get("text_primary")))
910 .labels(x_labels);
911 let y_axis = Axis::default()
912 .title("Count")
913 .bounds([y_min_bounds, y_max_bounds])
914 .style(Style::default().fg(theme.get("text_primary")))
915 .labels(y_labels);
916
917 let style = Style::default().fg(theme.get("primary_chart_series_color"));
918 let dataset = Dataset::default()
919 .name("")
920 .marker(symbols::Marker::HalfBlock)
921 .graph_type(GraphType::Bar)
922 .style(style)
923 .data(&series[0]);
924
925 Chart::new(vec![dataset])
926 .x_axis(x_axis)
927 .y_axis(y_axis)
928 .render(area, buf);
929}
930
931fn render_kde_chart(
932 area: Rect,
933 buf: &mut ratatui::buffer::Buffer,
934 modal: &ChartModal,
935 theme: &Theme,
936 data: Option<&KdeData>,
937 text_secondary: ratatui::style::Color,
938) {
939 let Some(data) = data else {
940 Paragraph::new("Select a column for KDE")
941 .style(Style::default().fg(text_secondary))
942 .centered()
943 .render(area, buf);
944 return;
945 };
946 if data.series.is_empty() {
947 Paragraph::new("No data for KDE")
948 .style(Style::default().fg(text_secondary))
949 .centered()
950 .render(area, buf);
951 return;
952 }
953
954 let series_colors = [
955 "chart_series_color_1",
956 "chart_series_color_2",
957 "chart_series_color_3",
958 "chart_series_color_4",
959 "chart_series_color_5",
960 "chart_series_color_6",
961 "chart_series_color_7",
962 ];
963
964 let datasets: Vec<Dataset> = data
965 .series
966 .iter()
967 .enumerate()
968 .map(|(i, s)| {
969 let color_key = series_colors
970 .get(i)
971 .copied()
972 .unwrap_or("primary_chart_series_color");
973 let style = Style::default().fg(theme.get(color_key));
974 Dataset::default()
975 .name(s.name.as_str())
976 .graph_type(GraphType::Line)
977 .marker(symbols::Marker::Braille)
978 .style(style)
979 .data(&s.points)
980 })
981 .collect();
982
983 let x_axis = Axis::default()
984 .title("Value")
985 .bounds([data.x_min, data.x_max])
986 .style(Style::default().fg(theme.get("text_primary")))
987 .labels(vec![
988 Span::styled(
989 format_axis_label(data.x_min),
990 Style::default().fg(theme.get("text_primary")),
991 ),
992 Span::styled(
993 format_axis_label((data.x_min + data.x_max) / 2.0),
994 Style::default().fg(theme.get("text_primary")),
995 ),
996 Span::styled(
997 format_axis_label(data.x_max),
998 Style::default().fg(theme.get("text_primary")),
999 ),
1000 ]);
1001 let y_axis = Axis::default()
1002 .title("Density")
1003 .bounds([0.0, data.y_max])
1004 .style(Style::default().fg(theme.get("text_primary")))
1005 .labels(vec![
1006 Span::styled(
1007 format_axis_label(0.0),
1008 Style::default().fg(theme.get("text_primary")),
1009 ),
1010 Span::styled(
1011 format_axis_label(data.y_max / 2.0),
1012 Style::default().fg(theme.get("text_primary")),
1013 ),
1014 Span::styled(
1015 format_axis_label(data.y_max),
1016 Style::default().fg(theme.get("text_primary")),
1017 ),
1018 ]);
1019
1020 let mut chart = Chart::new(datasets).x_axis(x_axis).y_axis(y_axis);
1021 if modal.show_legend {
1022 chart = chart.legend_position(Some(ratatui::widgets::LegendPosition::TopRight));
1023 } else {
1024 chart = chart.legend_position(None);
1025 }
1026 chart.render(area, buf);
1027}
1028
1029fn render_box_plot_chart(
1030 area: Rect,
1031 buf: &mut ratatui::buffer::Buffer,
1032 theme: &Theme,
1033 data: Option<&BoxPlotData>,
1034 text_secondary: ratatui::style::Color,
1035) {
1036 let Some(data) = data else {
1037 Paragraph::new("Select a column for box plot")
1038 .style(Style::default().fg(text_secondary))
1039 .centered()
1040 .render(area, buf);
1041 return;
1042 };
1043 if data.stats.is_empty() {
1044 Paragraph::new("No data for box plot")
1045 .style(Style::default().fg(text_secondary))
1046 .centered()
1047 .render(area, buf);
1048 return;
1049 }
1050
1051 let series_colors = [
1052 "chart_series_color_1",
1053 "chart_series_color_2",
1054 "chart_series_color_3",
1055 "chart_series_color_4",
1056 "chart_series_color_5",
1057 "chart_series_color_6",
1058 "chart_series_color_7",
1059 ];
1060 let mut segments: Vec<Vec<(f64, f64)>> = Vec::new();
1061 let mut segment_styles: Vec<Style> = Vec::new();
1062 let box_half = 0.3;
1063 let cap_half = 0.2;
1064 for (i, stat) in data.stats.iter().enumerate() {
1065 let x = i as f64;
1066 let color_key = series_colors
1067 .get(i)
1068 .copied()
1069 .unwrap_or("primary_chart_series_color");
1070 let style = Style::default().fg(theme.get(color_key));
1071 segments.push(vec![
1072 (x - box_half, stat.q1),
1073 (x + box_half, stat.q1),
1074 (x + box_half, stat.q3),
1075 (x - box_half, stat.q3),
1076 (x - box_half, stat.q1),
1077 ]);
1078 segment_styles.push(style);
1079 segments.push(vec![
1080 (x - box_half, stat.median),
1081 (x + box_half, stat.median),
1082 ]);
1083 segment_styles.push(style);
1084 segments.push(vec![(x, stat.min), (x, stat.q1)]);
1085 segment_styles.push(style);
1086 segments.push(vec![(x, stat.q3), (x, stat.max)]);
1087 segment_styles.push(style);
1088 segments.push(vec![(x - cap_half, stat.min), (x + cap_half, stat.min)]);
1089 segment_styles.push(style);
1090 segments.push(vec![(x - cap_half, stat.max), (x + cap_half, stat.max)]);
1091 segment_styles.push(style);
1092 }
1093
1094 let datasets: Vec<Dataset> = segments
1095 .iter()
1096 .zip(segment_styles.iter())
1097 .map(|(points, style)| {
1098 Dataset::default()
1099 .name("")
1100 .graph_type(GraphType::Line)
1101 .style(*style)
1102 .data(points)
1103 })
1104 .collect();
1105
1106 let x_min_bounds = -0.5;
1107 let x_max_bounds = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
1108 let axis_label_style = Style::default().fg(theme.get("text_primary"));
1109 let x_labels: Vec<Span> = data
1110 .stats
1111 .iter()
1112 .map(|s| Span::styled(s.name.as_str(), axis_label_style))
1113 .collect();
1114 let y_labels = vec![
1115 Span::styled(format_axis_label(data.y_min), axis_label_style),
1116 Span::styled(
1117 format_axis_label((data.y_min + data.y_max) / 2.0),
1118 axis_label_style,
1119 ),
1120 Span::styled(format_axis_label(data.y_max), axis_label_style),
1121 ];
1122
1123 let x_axis = Axis::default()
1124 .title("Columns")
1125 .bounds([x_min_bounds, x_max_bounds])
1126 .style(Style::default().fg(theme.get("text_primary")))
1127 .labels(x_labels);
1128 let y_axis = Axis::default()
1129 .title("Value")
1130 .bounds([data.y_min, data.y_max])
1131 .style(Style::default().fg(theme.get("text_primary")))
1132 .labels(y_labels);
1133
1134 Chart::new(datasets)
1135 .x_axis(x_axis)
1136 .y_axis(y_axis)
1137 .render(area, buf);
1138}
1139
1140fn render_heatmap_chart(
1141 area: Rect,
1142 buf: &mut ratatui::buffer::Buffer,
1143 theme: &Theme,
1144 data: Option<&HeatmapData>,
1145 text_secondary: ratatui::style::Color,
1146) {
1147 let Some(data) = data else {
1148 Paragraph::new("Select X and Y columns for heatmap")
1149 .style(Style::default().fg(text_secondary))
1150 .centered()
1151 .render(area, buf);
1152 return;
1153 };
1154 if data.counts.is_empty() || data.max_count <= 0.0 {
1155 Paragraph::new("No data for heatmap")
1156 .style(Style::default().fg(text_secondary))
1157 .centered()
1158 .render(area, buf);
1159 return;
1160 }
1161
1162 let layout = Layout::default()
1163 .direction(Direction::Vertical)
1164 .constraints([
1165 Constraint::Length(HEATMAP_TITLE_HEIGHT),
1166 Constraint::Min(1),
1167 Constraint::Length(HEATMAP_X_LABEL_HEIGHT),
1168 ])
1169 .split(area);
1170 let title = format!("{} vs {}", data.x_column, data.y_column);
1171 Paragraph::new(title)
1172 .style(Style::default().fg(theme.get("text_primary")))
1173 .render(layout[0], buf);
1174
1175 let y_labels = [
1176 format_axis_label(data.y_max),
1177 format_axis_label((data.y_min + data.y_max) / 2.0),
1178 format_axis_label(data.y_min),
1179 ];
1180 let y_label_width = y_labels.iter().map(|s| s.len()).max().unwrap_or(1) as u16;
1181 let y_label_width = y_label_width.clamp(4, 12);
1182 let body = Layout::default()
1183 .direction(Direction::Horizontal)
1184 .constraints([Constraint::Length(y_label_width + 1), Constraint::Min(1)])
1185 .split(layout[1]);
1186 let label_area = body[0];
1187 let plot_area = body[1];
1188 if plot_area.width == 0 || plot_area.height == 0 {
1189 return;
1190 }
1191
1192 let label_style = Style::default().fg(theme.get("text_primary"));
1193 if label_area.height >= 3 {
1194 buf.set_string(label_area.x, label_area.y, &y_labels[0], label_style);
1195 let mid_y = label_area.y + label_area.height / 2;
1196 buf.set_string(label_area.x, mid_y, &y_labels[1], label_style);
1197 let bottom_y = label_area.y + label_area.height.saturating_sub(1);
1198 buf.set_string(label_area.x, bottom_y, &y_labels[2], label_style);
1199 }
1200
1201 let intensity_chars: Vec<char> = " .:-=+*#%@".chars().collect();
1202 for row in 0..plot_area.height {
1203 for col in 0..plot_area.width {
1204 let max_x_bin = data.x_bins.saturating_sub(1) as f64;
1205 let max_y_bin = data.y_bins.saturating_sub(1) as f64;
1206 let x_bin = ((col as f64 / plot_area.width as f64) * data.x_bins as f64)
1207 .floor()
1208 .clamp(0.0, max_x_bin) as usize;
1209 let y_bin_raw = ((row as f64 / plot_area.height as f64) * data.y_bins as f64).floor();
1210 let y_bin = data
1211 .y_bins
1212 .saturating_sub(1)
1213 .saturating_sub(y_bin_raw.clamp(0.0, max_y_bin) as usize);
1214 let count = data.counts[y_bin][x_bin];
1215 let level = ((count / data.max_count) * (intensity_chars.len() as f64 - 1.0))
1216 .round()
1217 .clamp(0.0, intensity_chars.len() as f64 - 1.0) as usize;
1218 let ch = intensity_chars[level];
1219 let cell = &mut buf[(plot_area.x + col, plot_area.y + row)];
1220 let symbol = ch.to_string();
1221 cell.set_symbol(&symbol);
1222 cell.set_style(Style::default().fg(theme.get("primary_chart_series_color")));
1223 }
1224 }
1225
1226 let x_labels = [
1227 format_axis_label(data.x_min),
1228 format_axis_label((data.x_min + data.x_max) / 2.0),
1229 format_axis_label(data.x_max),
1230 ];
1231 let x_label_area = layout[2];
1232 let mid_x = x_label_area.x + x_label_area.width / 2;
1233 let right_x = x_label_area.x + x_label_area.width.saturating_sub(1);
1234 buf.set_string(x_label_area.x, x_label_area.y, &x_labels[0], label_style);
1235 buf.set_string(
1236 mid_x.saturating_sub((x_labels[1].len() / 2) as u16),
1237 x_label_area.y,
1238 &x_labels[1],
1239 label_style,
1240 );
1241 buf.set_string(
1242 right_x.saturating_sub(x_labels[2].len() as u16),
1243 x_label_area.y,
1244 &x_labels[2],
1245 label_style,
1246 );
1247 let x_title = format!("X: {}", data.x_column);
1248 let y_title = format!("Y: {}", data.y_column);
1249 if x_label_area.height > 1 {
1250 buf.set_string(x_label_area.x, x_label_area.y + 1, &x_title, label_style);
1251 buf.set_string(
1252 x_label_area.x + x_label_area.width.saturating_sub(y_title.len() as u16),
1253 x_label_area.y + 1,
1254 &y_title,
1255 label_style,
1256 );
1257 }
1258}