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 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
48/// Renders a single axis column list (shared by X and Y). Display order: selected (remembered) items first.
49/// Remembered items use modal_border_active; others use text_primary. Selected row uses REVERSED (like main datatable).
50fn 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), // Input row
114            Constraint::Length(1), // Divider row
115            Constraint::Min(3),    // List
116        ])
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
162/// Renders the chart view: title, left sidebar (chart type, x/y inputs+lists, checkboxes), and chart area (no border).
163/// 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.
164pub 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    // Sidebar (border, title "Options")
221    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), // Plot style label
242                    Constraint::Length(1), // Plot style radio grid
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_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), // Column label
418                    Constraint::Min(4),    // Column selector
419                    Constraint::Length(1), // Bins
420                    Constraint::Length(1), // Limit Rows
421                ])
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), // Column label
462                    Constraint::Min(4),    // Column selector
463                    Constraint::Length(1), // Limit Rows
464                ])
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), // Column label
497                    Constraint::Min(4),    // Column selector
498                    Constraint::Length(1), // Bandwidth
499                    Constraint::Length(1), // Limit Rows
500                ])
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), // X label
543                    Constraint::Min(4),    // X selector
544                    Constraint::Length(1), // Spacer
545                    Constraint::Length(1), // Y label
546                    Constraint::Min(4),    // Y selector
547                    Constraint::Length(1), // Bins
548                    Constraint::Length(1), // Limit Rows
549                ])
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            // Data is already in display form (log-scaled when log_scale) from cache; use as-is.
736            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}