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