Skip to main content

datui_lib/widgets/
chart.rs

1//! Chart view widget: sidebar (type, x/y columns, options) and chart area.
2
3use 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
49/// Renders a single axis column list (shared by X and Y). Display order: selected (remembered) items first.
50/// Remembered items use modal_border_active; others use text_primary. Selected row uses REVERSED (like main datatable).
51fn 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), // Input row
115            Constraint::Length(1), // Divider row
116            Constraint::Min(3),    // List
117        ])
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
163/// Renders the chart view: title, left sidebar (chart type, x/y inputs+lists, checkboxes), and chart area (no border).
164/// When only x is selected (no chart data), `x_bounds` may be `Some((min, max))` from the x column so the x axis shows the proper range.
165pub 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    // Sidebar (border, title "Options")
222    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), // Plot style radio block
243                    Constraint::Length(1), // Padding between style and X axis
244                    Constraint::Length(1), // X axis label
245                    Constraint::Min(4),    // X axis box (input + list)
246                    Constraint::Length(1), // Space between X and Y groups
247                    Constraint::Length(1), // Y axis label
248                    Constraint::Min(4),    // Y axis box (input + list)
249                    Constraint::Length(1), // Start y axis at 0
250                    Constraint::Length(1), // Log Scale
251                    Constraint::Length(1), // Legend
252                    Constraint::Length(1), // Limit Rows
253                ])
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), // Column label
401                    Constraint::Min(4),    // Column selector
402                    Constraint::Length(1), // Bins
403                    Constraint::Length(1), // Limit Rows
404                ])
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), // Column label
445                    Constraint::Min(4),    // Column selector
446                    Constraint::Length(1), // Limit Rows
447                ])
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), // Column label
480                    Constraint::Min(4),    // Column selector
481                    Constraint::Length(1), // Bandwidth
482                    Constraint::Length(1), // Limit Rows
483                ])
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), // X label
526                    Constraint::Min(4),    // X selector
527                    Constraint::Length(1), // Spacer
528                    Constraint::Length(1), // Y label
529                    Constraint::Min(4),    // Y selector
530                    Constraint::Length(1), // Bins
531                    Constraint::Length(1), // Limit Rows
532                ])
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            // Data is already in display form (log-scaled when log_scale) from cache; use as-is.
719            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}