Skip to main content

datui_lib/widgets/
analysis.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Direction, Layout, Rect},
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{
7        Bar, BarChart, BarGroup, Block, Cell, Chart, Dataset, GraphType, HighlightSpacing,
8        Paragraph, Row, StatefulWidget, Table, TableState, Widget,
9    },
10};
11
12use crate::analysis::analysis_modal::{
13    AnalysisFocus, AnalysisTool, AnalysisView, ColumnScroll, HistogramScale,
14};
15use crate::analysis::distribution_fit::{FitOutcome, FitTest};
16use crate::analysis::statistics::{
17    AnalysisResults, CategoricalStatistics, ColumnStatistics, CorrelationMethod,
18    DistributionAnalysis, DistributionType, HistogramKey, NumericStatistics, TemporalStatistics,
19};
20use crate::config::Theme;
21use crate::glyphs::PlotMarks;
22use crate::numfmt::{self, NumberFormatSettings};
23use crate::render::context::RenderContext;
24use crate::widgets::axes::{AxisSpec, PlotAxes};
25use crate::widgets::axis_numbers::{AxisFormat, AxisNumbers};
26use crate::widgets::ui::Surface;
27use polars::prelude::{AnyValue, DataType};
28
29pub struct AnalysisWidgetConfig<'a> {
30    pub results: Option<&'a AnalysisResults>,
31    pub view: AnalysisView,
32    pub selected_tool: Option<AnalysisTool>,
33    pub selected_correlation: Option<(usize, usize)>,
34    pub correlation_method: CorrelationMethod,
35    pub focus: AnalysisFocus,
36    pub selected_theoretical_distribution: DistributionType,
37    pub histogram_scale: HistogramScale,
38    pub theme: &'a Theme,
39    pub table_cell_padding: u16,
40    /// Display-time number formatting, so counts here match the data table.
41    pub number_format: &'a NumberFormatSettings,
42    /// The shared sample the results were read with, for the header.
43    pub sample: &'a crate::analysis::sampling::Sample,
44    pub ctx: &'a RenderContext,
45}
46
47pub struct AnalysisWidget<'a> {
48    results: Option<&'a AnalysisResults>,
49    view: AnalysisView,
50    selected_tool: Option<AnalysisTool>,
51    table_state: &'a mut TableState,
52    distribution_table_state: &'a mut TableState,
53    correlation_table_state: &'a mut TableState,
54    sidebar_state: &'a mut TableState,
55    selected_correlation: Option<(usize, usize)>,
56    correlation_method: CorrelationMethod,
57    focus: AnalysisFocus,
58    selected_theoretical_distribution: DistributionType,
59    distribution_selector_state: &'a mut TableState,
60    histogram_scale: HistogramScale,
61    theme: &'a Theme,
62    table_cell_padding: u16,
63    number_format: &'a NumberFormatSettings,
64    sample: &'a crate::analysis::sampling::Sample,
65    ctx: &'a RenderContext,
66    /// The selected tool's statistic scroll; the table sets how far it goes.
67    column_scroll: &'a mut ColumnScroll,
68}
69
70impl<'a> AnalysisWidget<'a> {
71    pub fn new(
72        config: AnalysisWidgetConfig<'a>,
73        table_state: &'a mut TableState,
74        distribution_table_state: &'a mut TableState,
75        correlation_table_state: &'a mut TableState,
76        sidebar_state: &'a mut TableState,
77        distribution_selector_state: &'a mut TableState,
78        column_scroll: &'a mut ColumnScroll,
79    ) -> Self {
80        Self {
81            results: config.results,
82            view: config.view,
83            selected_tool: config.selected_tool,
84            table_state,
85            distribution_table_state,
86            correlation_table_state,
87            sidebar_state,
88            selected_correlation: config.selected_correlation,
89            correlation_method: config.correlation_method,
90            focus: config.focus,
91            selected_theoretical_distribution: config.selected_theoretical_distribution,
92            distribution_selector_state,
93            histogram_scale: config.histogram_scale,
94            theme: config.theme,
95            table_cell_padding: config.table_cell_padding,
96            number_format: config.number_format,
97            sample: config.sample,
98            ctx: config.ctx,
99            column_scroll,
100        }
101    }
102}
103
104impl<'a> Widget for AnalysisWidget<'a> {
105    fn render(self, area: Rect, buf: &mut Buffer) {
106        match self.view {
107            AnalysisView::Main => self.render_main_view(area, buf),
108            AnalysisView::DistributionDetail => self.render_distribution_detail(area, buf),
109            AnalysisView::CorrelationDetail => self.render_correlation_detail(area, buf),
110        }
111    }
112}
113
114impl<'a> AnalysisWidget<'a> {
115    fn render_main_view(self, area: Rect, buf: &mut Buffer) {
116        // The tool list never takes more than a third of the screen: the
117        // results are what the screen is for.
118        let sidebar_width = sidebar_width(area.width);
119
120        // Full-screen layout: breadcrumb, main area (no separate keybind hints line)
121        let layout = Layout::default()
122            .direction(Direction::Vertical)
123            .constraints([
124                Constraint::Length(1), // Breadcrumb
125                Constraint::Fill(1),   // Main area + sidebar
126            ])
127            .split(area);
128
129        // Breadcrumb: tool name when a tool is selected, or "Analysis" when none selected
130        let tool_name = match self.selected_tool {
131            Some(AnalysisTool::Describe) => "Describe".to_string(),
132            Some(AnalysisTool::DistributionAnalysis) => "Distribution Analysis".to_string(),
133            // The coefficient is part of the title: the cells do not say which it is.
134            Some(AnalysisTool::CorrelationMatrix) => format!(
135                "Correlation Matrix {} {}",
136                crate::glyphs::get().middot,
137                coefficient_name(self.correlation_method)
138            ),
139            Some(AnalysisTool::DataQuality) => "Data Quality".to_string(),
140            None => "Analysis".to_string(),
141        };
142
143        // What the numbers are of: a sample says its size, of how many, and which rows.
144        let breadcrumb_text = match self.results {
145            Some(results) if self.selected_tool.is_some() => format!(
146                "{tool_name} {} {}",
147                crate::glyphs::get().middot,
148                self.sample
149                    .outcome(results.total_rows, results.sample_size, results.per_value)
150            ),
151            _ => tool_name,
152        };
153
154        let header_row_style = header_style(self.theme.controls_bg(), self.theme.table_header());
155        Paragraph::new(breadcrumb_text)
156            .style(header_row_style)
157            .render(layout[0], buf);
158
159        let main_layout = Layout::default()
160            .direction(Direction::Horizontal)
161            .constraints([
162                Constraint::Fill(1),               // Main content area
163                Constraint::Length(sidebar_width), // Sidebar
164            ])
165            .split(layout[1]);
166
167        // Main content area: instructions when no tool selected, else selected tool (or "Computing...")
168        match self.selected_tool {
169            None => {
170                const INSTRUCTION_LINES: u16 = 1;
171                let inner = Layout::default()
172                    .direction(Direction::Vertical)
173                    .constraints([
174                        Constraint::Min(0),
175                        Constraint::Length(INSTRUCTION_LINES),
176                        Constraint::Min(0),
177                    ])
178                    .split(main_layout[0]);
179                Paragraph::new("Pick a tool in the sidebar")
180                    .centered()
181                    .style(Style::default().fg(self.theme.text_primary()))
182                    .render(inner[1], buf);
183            }
184            Some(tool) => {
185                if let Some(results) = self.results {
186                    match tool {
187                        AnalysisTool::Describe => {
188                            StatisticsTable {
189                                results,
190                                focused: self.focus == AnalysisFocus::Main,
191                                theme: self.theme,
192                                table_cell_padding: self.table_cell_padding,
193                                number_format: self.number_format,
194                            }
195                            .render(
196                                main_layout[0],
197                                buf,
198                                self.table_state,
199                                self.column_scroll,
200                            );
201                        }
202                        AnalysisTool::DistributionAnalysis => {
203                            render_distribution_table(
204                                results,
205                                self.distribution_table_state,
206                                self.column_scroll,
207                                self.focus == AnalysisFocus::Main,
208                                main_layout[0],
209                                buf,
210                                self.theme,
211                            );
212                        }
213                        AnalysisTool::CorrelationMatrix => {
214                            render_correlation_matrix(
215                                results.correlation_matrix.as_ref().map(|matrix| Shown {
216                                    matrix,
217                                    method: self.correlation_method,
218                                }),
219                                self.correlation_table_state,
220                                MatrixCursor {
221                                    cell: self.selected_correlation,
222                                    focused: self.focus == AnalysisFocus::Main,
223                                },
224                                self.column_scroll,
225                                main_layout[0],
226                                buf,
227                                self.theme,
228                            );
229                        }
230                        AnalysisTool::DataQuality => {
231                            Paragraph::new("Data Quality")
232                                .centered()
233                                .render(main_layout[0], buf);
234                        }
235                    }
236                }
237                // No result yet: the Sample form fills this pane until the first run,
238                // and the progress overlay covers it during one.
239            }
240        }
241
242        // Sidebar: Tool list
243        render_sidebar(
244            main_layout[1],
245            buf,
246            self.sidebar_state,
247            self.selected_tool,
248            self.focus,
249            self.theme,
250        );
251
252        // Keybind hints are now shown on the main bottom bar (see lib.rs)
253    }
254
255    fn render_distribution_detail(self, area: Rect, buf: &mut Buffer) {
256        let selected_idx = self.distribution_table_state.selected();
257        let dist_analysis: Option<&DistributionAnalysis> = self.results.and_then(|results| {
258            selected_idx.and_then(|idx| results.distribution_analyses.get(idx))
259        });
260
261        if let Some(dist) = dist_analysis {
262            // Layout: breadcrumb, main content (no keybind hints line)
263            let layout = Layout::default()
264                .direction(Direction::Vertical)
265                .constraints([
266                    Constraint::Length(1), // Breadcrumb
267                    Constraint::Fill(1),   // Main content
268                ])
269                .split(area);
270
271            // The breadcrumb carries the name alone; the footer says Esc.
272            let title_text = format!("Distribution Analysis: {}", dist.column_name);
273            let header_row_style =
274                header_style(self.theme.controls_bg(), self.theme.table_header());
275            Paragraph::new(title_text)
276                .style(header_row_style)
277                .render(layout[0], buf);
278
279            // The key figures over the charts, wrapped rather than cut: at 60
280            // columns they take three lines.
281            let stats = condensed_statistics_lines(
282                &condensed_statistics(dist),
283                layout[1].width,
284                self.theme,
285            );
286            let stats_height = (stats.len() as u16).clamp(1, 3);
287            let main_layout = Layout::default()
288                .direction(Direction::Vertical)
289                .constraints([Constraint::Length(stats_height), Constraint::Fill(1)])
290                .split(layout[1]);
291            Paragraph::new(stats).render(main_layout[0], buf);
292
293            // Charts on the left; the family list on the right, never narrower than
294            // its longest name and p-value.
295            let content_layout = Layout::default()
296                .direction(Direction::Horizontal)
297                .constraints([
298                    Constraint::Fill(1),
299                    Constraint::Length(SELECTOR_WIDTH.max(main_layout[1].width / 4)),
300                ])
301                .split(main_layout[1]);
302
303            // Left side: Q-Q plot and histogram with spacing
304            let charts_layout = Layout::default()
305                .direction(Direction::Vertical)
306                .constraints([
307                    Constraint::Percentage(52), // Q-Q plot (slightly reduced to make room for spacing)
308                    Constraint::Length(1),      // Vertical spacing between charts
309                    Constraint::Percentage(47), // Histogram (slightly reduced to make room for spacing)
310                ])
311                .split(content_layout[0]);
312
313            let chart_padding = 1u16; // 1 character padding on all sides
314            let right_padding_extra = 1u16; // Extra padding on right side to separate from distribution box
315            let top_padding_extra = 1u16; // Extra padding at top to separate title from chart
316            let qq_plot_area = Rect::new(
317                charts_layout[0].left() + chart_padding,
318                charts_layout[0].top() + chart_padding + top_padding_extra, // Extra top padding
319                charts_layout[0]
320                    .width
321                    .saturating_sub(chart_padding) // Left padding
322                    .saturating_sub(right_padding_extra), // Extra right padding
323                charts_layout[0]
324                    .height
325                    .saturating_sub(chart_padding * 2)
326                    .saturating_sub(top_padding_extra), // Account for extra top padding
327            );
328            let histogram_area = Rect::new(
329                charts_layout[2].left() + chart_padding,
330                charts_layout[2].top() + chart_padding + top_padding_extra, // Extra top padding
331                charts_layout[2]
332                    .width
333                    .saturating_sub(chart_padding) // Left padding
334                    .saturating_sub(right_padding_extra), // Extra right padding
335                charts_layout[2]
336                    .height
337                    .saturating_sub(chart_padding * 2)
338                    .saturating_sub(top_padding_extra), // Account for extra top padding
339            );
340
341            // The value axes, both x axes and the Q-Q plot's y, read the column's
342            // numbers over the sample's range; the histogram's y reads counts.
343            let values = AxisNumbers::measure(self.number_format, &dist.column_name);
344            let counts = AxisNumbers::count(self.number_format);
345            let sorted_data = &dist.sorted_sample_values;
346            let unified_x_range = match (sorted_data.first(), sorted_data.last()) {
347                (Some(&lo), Some(&hi)) => (lo, hi),
348                _ => (0.0, 1.0),
349            };
350
351            // Both plots share a y-label width so they start in the same column.
352            let (lo, hi) = unified_x_range;
353            let qq_format = AxisFormat::ends_and_middle([lo, hi], &values);
354            let qq_width = [lo, (lo + hi) / 2.0, hi]
355                .iter()
356                .filter_map(|&v| qq_format.label(v, 0))
357                .map(|l| crate::glyphs::display_width(&l))
358                .max()
359                .unwrap_or(1);
360            let n = sorted_data.len() as f64;
361            let count_width = AxisFormat::new(&[0.0, n], &counts)
362                .label(n, 0)
363                .map_or(1, |l| crate::glyphs::display_width(&l));
364            let shared_y_axis_label_width = (qq_width.max(count_width) as u16).max(1) + 1;
365
366            // Both plots of the selected theoretical distribution, on one x range.
367            let plot = DistributionPlotConfig {
368                dist,
369                dist_type: self.selected_theoretical_distribution,
370                area: qq_plot_area,
371                shared_y_axis_label_width,
372                theme: self.theme,
373                unified_x_range: Some(unified_x_range),
374                histogram_scale: self.histogram_scale,
375                glyphs: crate::glyphs::get(),
376                values: &values,
377                counts: &counts,
378            };
379            render_qq_plot(plot, buf);
380
381            // Check if log scale is requested but can't be used
382            // Use actual data values, not unified range (which may include theoretical bounds and padding)
383            let sorted_data = &dist.sorted_sample_values;
384            let can_use_log_scale = !sorted_data.is_empty() && sorted_data.iter().all(|&v| v > 0.0);
385            let log_scale_requested_but_unavailable =
386                matches!(self.histogram_scale, HistogramScale::Log) && !can_use_log_scale;
387
388            render_distribution_histogram(
389                DistributionPlotConfig {
390                    area: histogram_area,
391                    ..plot
392                },
393                buf,
394            );
395
396            render_distribution_selector(
397                SelectorConfig {
398                    dist,
399                    selected: self.selected_theoretical_distribution,
400                    histogram_scale: self.histogram_scale,
401                    log_scale_unavailable: log_scale_requested_but_unavailable,
402                    theme: self.theme,
403                    ctx: self.ctx,
404                },
405                self.distribution_selector_state,
406                content_layout[1],
407                buf,
408            );
409
410        // No keybind hints line - removed
411        } else {
412            Paragraph::new("No distribution selected")
413                .centered()
414                .render(area, buf);
415        }
416    }
417
418    fn render_correlation_detail(self, area: Rect, buf: &mut Buffer) {
419        let matrix = self
420            .results
421            .and_then(|results| results.correlation_matrix.as_ref());
422        let pair = self.selected_correlation.and_then(|(row, col)| {
423            matrix.and_then(|m| {
424                (row < m.columns.len() && col < m.columns.len()).then_some((row, col))
425            })
426        });
427
428        let (Some(matrix), Some((row, col))) = (matrix, pair) else {
429            Paragraph::new("No correlation pair selected")
430                .centered()
431                .render(area, buf);
432            return;
433        };
434
435        let layout = Layout::default()
436            .direction(Direction::Vertical)
437            .constraints([Constraint::Length(1), Constraint::Fill(1)])
438            .split(area);
439
440        // The breadcrumb carries the pair alone; the footer says Esc.
441        let title_text = format!(
442            "Correlation: {} vs {}",
443            matrix.columns[row], matrix.columns[col]
444        );
445        let header_row_style = header_style(self.theme.controls_bg(), self.theme.table_header());
446        Paragraph::new(title_text)
447            .style(header_row_style)
448            .render(layout[0], buf);
449
450        let total_rows = self.results.map(|r| r.total_rows).unwrap_or(0);
451        render_correlation_pair_summary(
452            Shown {
453                matrix,
454                method: self.correlation_method,
455            },
456            (row, col),
457            total_rows,
458            layout[1],
459            buf,
460            self.theme,
461            self.number_format,
462        );
463    }
464}
465
466/// The correlation matrix as the screen shows it: by the method chosen.
467#[derive(Clone, Copy)]
468struct Shown<'a> {
469    matrix: &'a crate::analysis::statistics::CorrelationMatrix,
470    method: CorrelationMethod,
471}
472
473/// Why a matrix has no Spearman: the rows read hold more values than it ranks.
474const SPEARMAN_TOO_MANY: &str = "Too many values to rank for Spearman; s chooses a smaller sample";
475
476/// The coefficient's name and symbol, as the matrix title and the pair detail
477/// give it.
478fn coefficient_name(method: CorrelationMethod) -> String {
479    match method {
480        CorrelationMethod::Pearson => "Pearson r".to_string(),
481        CorrelationMethod::Spearman => format!("Spearman {}", crate::glyphs::get().rho),
482    }
483}
484
485/// `r` at `decimals` places, where rounding never makes it ±1 unless it is: a
486/// near-perfect 0.9996 reads 0.999 at three places, not a perfect 1.000.
487fn format_coefficient(r: f64, decimals: usize) -> String {
488    let text = format!("{r:.decimals$}");
489    if r.abs() < 1.0 && text.trim_start_matches('-').starts_with('1') {
490        let sign = if r < 0.0 { "-" } else { "" };
491        format!("{sign}0.{}", "9".repeat(decimals))
492    } else {
493        text
494    }
495}
496
497/// A short reading of a coefficient, using the same 0.05/0.3 boundaries as the
498/// matrix's colors so the word never disagrees with the color.
499fn describe_correlation(r: f64) -> &'static str {
500    let strength = r.abs();
501    if strength < 0.05 {
502        "none"
503    } else if strength < 0.3 {
504        if r > 0.0 {
505            "weak positive"
506        } else {
507            "weak negative"
508        }
509    } else if strength < 0.7 {
510        if r > 0.0 {
511            "moderate positive"
512        } else {
513            "moderate negative"
514        }
515    } else if r > 0.0 {
516        "strong positive"
517    } else {
518        "strong negative"
519    }
520}
521
522/// The correlation pair detail: what the matrix knows of the pair (no scatter or
523/// per-column moments: the results lack the values).
524fn render_correlation_pair_summary(
525    Shown { matrix, method }: Shown,
526    (row, col): (usize, usize),
527    total_rows: usize,
528    area: Rect,
529    buf: &mut Buffer,
530    theme: &Theme,
531    number_format: &NumberFormatSettings,
532) {
533    let r = matrix.coefficient(method, row, col);
534    let pairs = matrix.sample_sizes[row][col];
535    let p_value = matrix.p_value(method, row, col);
536
537    let label_style = Style::default().fg(theme.text_secondary());
538    let value_style = Style::default().fg(theme.text_primary());
539
540    let mut lines: Vec<Line> = Vec::new();
541    if r.is_nan() {
542        let unranked = method == CorrelationMethod::Spearman && matrix.rank_correlations.is_none();
543        let why = if unranked {
544            SPEARMAN_TOO_MANY
545        } else if pairs < 3 {
546            "Fewer than 3 overlapping pairs"
547        } else {
548            "A column holds one value"
549        };
550        lines.push(Line::from(vec![Span::styled(why, value_style)]));
551    } else {
552        lines.push(Line::from(vec![
553            Span::styled(format!("{}: ", coefficient_name(method)), label_style),
554            Span::styled(
555                format_coefficient(r, 4),
556                Style::default().fg(get_correlation_color(r, theme)),
557            ),
558            Span::styled(format!("  ({})", describe_correlation(r)), value_style),
559        ]));
560        lines.push(Line::from(vec![
561            Span::styled(format!("{}: ", crate::glyphs::get().r_squared), label_style),
562            Span::styled(format_coefficient(r * r, 4), value_style),
563        ]));
564        if let Some(p) = p_value {
565            lines.push(Line::from(vec![
566                Span::styled("P-value: ", label_style),
567                Span::styled(format_pvalue(p), value_style),
568            ]));
569        }
570    }
571    lines.push(Line::from(vec![
572        Span::styled("Pairs used: ", label_style),
573        Span::styled(
574            format!(
575                "{} of {} rows",
576                format_count(pairs, number_format),
577                format_count(total_rows, number_format)
578            ),
579            value_style,
580        ),
581    ]));
582
583    // One character of margin, like the distribution detail's charts.
584    let inner = Rect::new(
585        area.left() + 1,
586        area.top() + 1,
587        area.width.saturating_sub(2),
588        area.height.saturating_sub(1),
589    );
590    Paragraph::new(lines).render(inner, buf);
591}
592
593/// The Describe tool's table: a row per column, a column per statistic.
594struct StatisticsTable<'a> {
595    results: &'a AnalysisResults,
596    focused: bool,
597    theme: &'a Theme,
598    table_cell_padding: u16,
599    number_format: &'a NumberFormatSettings,
600}
601
602impl StatisticsTable<'_> {
603    fn render(
604        self,
605        area: Rect,
606        buf: &mut Buffer,
607        table_state: &mut TableState,
608        columns: &mut ColumnScroll,
609    ) {
610        let StatisticsTable {
611            results,
612            focused,
613            theme,
614            table_cell_padding,
615            number_format,
616        } = self;
617        let num_columns = results.column_statistics.len();
618        if num_columns == 0 {
619            Paragraph::new("No columns to display")
620                .centered()
621                .render(area, buf);
622            return;
623        }
624
625        // Statistics to display (in order) - internal names for matching data
626        let stat_names = vec![
627            "count",
628            "null_count",
629            "mean",
630            "std",
631            "min",
632            "25%",
633            "50%",
634            "75%",
635            "max",
636        ];
637        // Display names in Title case for headers
638        let stat_display_names = vec![
639            "Count", "Nulls", "Mean", "Std", "Min", "25%", "50%", "75%", "Max",
640        ];
641        let num_stats = stat_names.len();
642
643        // Column widths from header names and content; ratatui's Table adds one space between
644        // columns.
645        let mut min_col_widths: Vec<u16> = stat_display_names
646            .iter()
647            .map(|name| crate::glyphs::display_width(name) as u16) // header length (no extra padding - table handles spacing)
648            .collect();
649
650        // Scan all data to find maximum width needed for each column
651        for col_stat in &results.column_statistics {
652            for (stat_idx, stat_name) in stat_names.iter().enumerate() {
653                let value_str = describe_value(col_stat, stat_name, number_format);
654                let value_len = crate::glyphs::display_width(&value_str) as u16;
655                // Ensure width is at least the header length (already initialized) AND value length
656                // This preserves header widths even if all data values are shorter
657                let header_len = crate::glyphs::display_width(stat_display_names[stat_idx]) as u16;
658                min_col_widths[stat_idx] = min_col_widths[stat_idx].max(value_len).max(header_len);
659                // must fit both header and content (no padding - table handles spacing)
660            }
661        }
662
663        // Locked column width (column name) - calculate from header text AND actual column names
664        let header_text = "Column";
665        let header_len = crate::glyphs::display_width(header_text) as u16;
666        let max_col_name_len = results
667            .column_statistics
668            .iter()
669            .map(|cs| crate::glyphs::display_width(&cs.name) as u16)
670            .max()
671            .unwrap_or(header_len);
672        let locked_col_width = max_col_name_len.max(header_len).max(10); // min 10, must fit both header and data (no padding - table handles spacing)
673
674        let column_spacing = table_cell_padding;
675        // The rail's column comes first, then the locked names.
676        let available_width = area
677            .width
678            .saturating_sub(RAIL_WIDTH + locked_col_width)
679            .saturating_sub(column_spacing);
680        let (start_stat, end_stat) =
681            stat_window(&min_col_widths, available_width, column_spacing, columns);
682        let visible_stats: Vec<usize> = (start_stat..end_stat).collect();
683
684        if visible_stats.is_empty() {
685            return;
686        }
687
688        let mut rows = Vec::new();
689
690        let mut header_cells = vec![Cell::from("Column").style(Style::default())];
691        for &stat_idx in &visible_stats {
692            header_cells.push(Cell::from(stat_display_names[stat_idx]).style(Style::default()));
693        }
694        let header_row_style = header_style(theme.controls_bg(), theme.table_header());
695        let header_row = Row::new(header_cells.clone()).style(header_row_style);
696
697        for col_stat in &results.column_statistics {
698            let mut cells = vec![
699                Cell::from(col_stat.name.as_str()).style(Style::default().fg(theme.text_primary())),
700            ];
701            for &stat_idx in &visible_stats {
702                let stat_name = stat_names[stat_idx];
703                let value = describe_value(col_stat, stat_name, number_format);
704
705                cells.push(Cell::from(value));
706            }
707
708            rows.push(Row::new(cells));
709        }
710
711        let mut constraints = vec![Constraint::Length(locked_col_width)];
712        for &stat_idx in &visible_stats {
713            constraints.push(Constraint::Length(min_col_widths[stat_idx]));
714        }
715
716        let table = Table::new(rows, constraints)
717            .header(header_row)
718            .column_spacing(table_cell_padding)
719            .row_highlight_style(cursor_style(focused, theme))
720            .highlight_symbol(cursor_rail(focused, theme))
721            .highlight_spacing(HighlightSpacing::Always);
722
723        StatefulWidget::render(table, area, buf, table_state);
724        draw_scroll_marks(
725            area,
726            buf,
727            RAIL_WIDTH + locked_col_width,
728            (start_stat, end_stat, num_stats),
729            theme,
730        );
731    }
732}
733
734/// One Describe cell. A date, time or duration column gets its range, quartiles
735/// and mean in its own format; the statistics it has no value for, and nulls, read `-`.
736fn describe_value(
737    col_stat: &ColumnStatistics,
738    stat_name: &str,
739    number_format: &NumberFormatSettings,
740) -> String {
741    let numeric = |f: fn(&NumericStatistics) -> f64| {
742        col_stat.numeric_stats.as_ref().map(|n| format_num(f(n)))
743    };
744    let temporal = |f: fn(&TemporalStatistics) -> &Option<String>| {
745        col_stat.temporal_stats.as_ref().and_then(|t| f(t).clone())
746    };
747    let categorical = |f: fn(&CategoricalStatistics) -> &Option<String>| {
748        col_stat
749            .categorical_stats
750            .as_ref()
751            .and_then(|c| f(c).clone())
752    };
753    match stat_name {
754        "count" => Some(format_count(col_stat.count, number_format)),
755        "null_count" => Some(format_count(col_stat.null_count, number_format)),
756        "mean" => numeric(|n| n.mean).or_else(|| temporal(|t| &t.mean)),
757        "std" => numeric(|n| n.std),
758        "min" => numeric(|n| n.min)
759            .or_else(|| temporal(|t| &t.min))
760            .or_else(|| categorical(|c| &c.min)),
761        "25%" => numeric(|n| n.q25).or_else(|| temporal(|t| &t.q25)),
762        "50%" => numeric(|n| n.median).or_else(|| temporal(|t| &t.median)),
763        "75%" => numeric(|n| n.q75).or_else(|| temporal(|t| &t.q75)),
764        "max" => numeric(|n| n.max)
765            .or_else(|| temporal(|t| &t.max))
766            .or_else(|| categorical(|c| &c.max)),
767        _ => None,
768    }
769    .unwrap_or_else(|| "-".to_string())
770}
771
772/// A row or null count, grouped as the data table is; float statistics use
773/// `format_num`, which switches to scientific notation first.
774fn format_count(n: usize, settings: &NumberFormatSettings) -> String {
775    let fmt = settings.formatter_for("", &DataType::UInt64);
776    let mut scratch = String::new();
777    numfmt::format_any_value(&fmt, &AnyValue::UInt64(n as u64), &mut scratch).into_owned()
778}
779
780fn format_num(n: f64) -> String {
781    if n.is_nan() {
782        "-".to_string()
783    } else if n.abs() >= 1000.0 || (n.abs() < 0.01 && n != 0.0) {
784        format!("{:.2e}", n)
785    } else {
786        format!("{:.2}", n)
787    }
788}
789
790// Phase 6: Format p-value with special handling for very small values
791fn format_pvalue(p: f64) -> String {
792    if p < 0.001 {
793        "<0.001".to_string()
794    } else {
795        format!("{:.3}", p)
796    }
797}
798
799/// The p-value beside a column's verdict: the chosen family's, or with no clear fit the
800/// best any family managed. A bound reads as one.
801fn verdict_pvalue(dist: &DistributionAnalysis) -> String {
802    let chosen = dist.fit(dist.distribution_type).and_then(FitOutcome::test);
803    let best = || {
804        dist.fits
805            .iter()
806            .filter_map(|(_, outcome)| outcome.test())
807            .max_by(|a, b| a.p_value.total_cmp(&b.p_value))
808    };
809    match chosen.or_else(best) {
810        Some(test) => format_fit_pvalue(test),
811        None => "N/A".to_string(),
812    }
813}
814
815/// A fit test's p-value: `<0.005` when no simulated sample reached the column's
816/// statistic, since then the p-value is only a bound.
817fn format_fit_pvalue(test: &FitTest) -> String {
818    if test.at_bound() {
819        format!("<{:.3}", test.p_value)
820    } else {
821        format!("{:.3}", test.p_value)
822    }
823}
824
825/// Holds, marginal, rejected.
826fn pvalue_style(p: f64, theme: &Theme) -> Style {
827    if p >= 0.05 {
828        Style::default().fg(theme.distribution_normal())
829    } else if p > 0.01 {
830        Style::default().fg(theme.distribution_skewed())
831    } else {
832        Style::default().fg(theme.outlier_marker())
833    }
834}
835
836/// A header's style: `bg` behind `fg`, or `fg` alone when `bg` is the terminal's own.
837pub(crate) fn header_style(bg: Color, fg: Color) -> Style {
838    if bg == Color::Reset {
839        Style::default().fg(fg)
840    } else {
841        Style::default().bg(bg).fg(fg)
842    }
843}
844
845fn render_distribution_table(
846    results: &AnalysisResults,
847    table_state: &mut TableState,
848    columns: &mut ColumnScroll,
849    focused: bool,
850    area: Rect,
851    buf: &mut Buffer,
852    theme: &Theme,
853) {
854    if results.distribution_analyses.is_empty() {
855        Paragraph::new("No numeric columns for distribution analysis")
856            .centered()
857            .render(area, buf);
858        return;
859    }
860
861    // Column headers for width calculation (excluding "Column" which will be locked)
862    // Phase 6: Add P-value column after Distribution
863    let column_names = [
864        "Distribution",
865        "P-value",
866        "Shapiro-Francia",
867        "SF p-value",
868        "CV",
869        "Outliers",
870        "Skewness",
871        "Kurtosis",
872    ];
873    let num_stats = column_names.len();
874
875    // Calculate column widths based on header names and content (minimal spacing)
876    // Note: ratatui Table adds 1 space between columns by default, so we don't add extra padding
877    let mut min_col_widths: Vec<u16> = column_names
878        .iter()
879        .map(|name| crate::glyphs::display_width(name) as u16) // header length (no extra padding - table handles spacing)
880        .collect();
881
882    let header_text = "Column";
883    let header_len = crate::glyphs::display_width(header_text) as u16;
884    let max_col_name_len = results
885        .distribution_analyses
886        .iter()
887        .map(|da| crate::glyphs::display_width(&da.column_name) as u16)
888        .max()
889        .unwrap_or(header_len);
890    let locked_col_width = max_col_name_len.max(header_len).max(10);
891
892    // Each row's values, formatted once for the widths and the cells both.
893    let texts: Vec<[String; 8]> = results
894        .distribution_analyses
895        .iter()
896        .map(stat_texts)
897        .collect();
898    for row in &texts {
899        for (idx, value) in row.iter().enumerate() {
900            min_col_widths[idx] =
901                min_col_widths[idx].max(crate::glyphs::display_width(value) as u16);
902        }
903    }
904
905    let column_spacing = 1u16;
906    // The rail's column comes first, then the locked names.
907    let available_width = area
908        .width
909        .saturating_sub(RAIL_WIDTH + locked_col_width)
910        .saturating_sub(column_spacing);
911    let (start_stat, end_stat) =
912        stat_window(&min_col_widths, available_width, column_spacing, columns);
913    let visible_stats: Vec<usize> = (start_stat..end_stat).collect();
914
915    if visible_stats.is_empty() {
916        return;
917    }
918
919    let mut rows = Vec::new();
920
921    let mut header_cells = vec![Cell::from("Column").style(Style::default())];
922    for &stat_idx in &visible_stats {
923        header_cells.push(Cell::from(column_names[stat_idx]).style(Style::default()));
924    }
925    let header_row_style = header_style(theme.controls_bg(), theme.table_header());
926    let header_row = Row::new(header_cells).style(header_row_style);
927    for (dist_analysis, texts) in results.distribution_analyses.iter().zip(texts) {
928        // The verdict in the colors of its p-value; no clear fit in the rejected one.
929        let type_color = match dist_analysis.distribution_type {
930            DistributionType::Unknown => theme.outlier_marker(),
931            DistributionType::Constant => theme.text_primary(),
932            _ => pvalue_style(dist_analysis.confidence, theme)
933                .fg
934                .unwrap_or_else(|| theme.text_primary()),
935        };
936
937        // Relaxed outlier color thresholds - red only for very high percentages that might indicate data errors
938        let outlier_style = if dist_analysis.outliers.percentage > 20.0 {
939            // Red: very high outlier percentage (>20%) - might indicate data errors
940            Style::default().fg(theme.outlier_marker())
941        } else if dist_analysis.outliers.percentage > 5.0 {
942            // Yellow for moderate outliers (5-20%)
943            Style::default().fg(theme.distribution_skewed())
944        } else {
945            // Default (white) for low outlier percentages (0-5%)
946            Style::default()
947        };
948
949        let skewness_value = dist_analysis.characteristics.skewness.abs();
950        let kurtosis_value = dist_analysis.characteristics.kurtosis;
951
952        // Skewness color coding: similar to describe table
953        let skewness_style = if skewness_value >= 3.0 {
954            Style::default().fg(theme.outlier_marker())
955        } else if skewness_value >= 1.0 {
956            Style::default().fg(theme.distribution_skewed())
957        } else {
958            Style::default()
959        };
960
961        // Kurtosis color coding: 3.0 is normal, high/low is notable
962        let kurtosis_style = if (kurtosis_value - 3.0).abs() >= 3.0 {
963            Style::default().fg(theme.outlier_marker())
964        } else if (kurtosis_value - 3.0).abs() >= 1.0 {
965            Style::default().fg(theme.distribution_skewed())
966        } else {
967            Style::default()
968        };
969
970        let pvalue_style = pvalue_style(dist_analysis.confidence, theme);
971
972        // Color coding for SW p-value: same semantics as p-value column
973        // Green = normal (>0.05), Yellow = moderate (0.01-0.05), Red = non-normal (≤0.01)
974        let sw_pvalue_style = dist_analysis
975            .characteristics
976            .shapiro_wilk_pvalue
977            .map(|p| {
978                if p > 0.05 {
979                    Style::default().fg(theme.distribution_normal())
980                } else if p > 0.01 {
981                    Style::default().fg(theme.distribution_skewed())
982                } else {
983                    Style::default().fg(theme.outlier_marker())
984                }
985            })
986            .unwrap_or_default();
987
988        // Build row with locked column name + visible stat values
989        // Use explicit text_primary so column names stay visible (avoids black-on-black)
990        let mut cells = vec![
991            Cell::from(dist_analysis.column_name.as_str())
992                .style(Style::default().fg(theme.text_primary())),
993        ];
994
995        let cv_style = if dist_analysis.characteristics.coefficient_of_variation > 1.0 {
996            // High variability.
997            Style::default().fg(theme.distribution_skewed())
998        } else {
999            Style::default()
1000        };
1001        let styles = [
1002            Style::default().fg(type_color),
1003            pvalue_style,
1004            Style::default(),
1005            sw_pvalue_style,
1006            cv_style,
1007            outlier_style,
1008            skewness_style,
1009            kurtosis_style,
1010        ];
1011        let mut texts = texts.map(Some);
1012        for &stat_idx in &visible_stats {
1013            let text = texts[stat_idx].take().unwrap_or_default();
1014            cells.push(Cell::from(text).style(styles[stat_idx]));
1015        }
1016
1017        rows.push(Row::new(cells));
1018    }
1019
1020    let mut constraints = vec![Constraint::Length(locked_col_width)];
1021    for &stat_idx in &visible_stats {
1022        constraints.push(Constraint::Length(min_col_widths[stat_idx]));
1023    }
1024
1025    if visible_stats.len() == num_stats && constraints.len() > 1 {
1026        let last_idx = constraints.len() - 1;
1027        constraints[last_idx] = Constraint::Fill(1);
1028    }
1029
1030    let table = Table::new(rows, constraints)
1031        .header(header_row)
1032        .row_highlight_style(cursor_style(focused, theme))
1033        .highlight_symbol(cursor_rail(focused, theme))
1034        .highlight_spacing(HighlightSpacing::Always);
1035
1036    StatefulWidget::render(table, area, buf, table_state);
1037    draw_scroll_marks(
1038        area,
1039        buf,
1040        RAIL_WIDTH + locked_col_width,
1041        (start_stat, end_stat, num_stats),
1042        theme,
1043    );
1044}
1045
1046/// One distribution's values in the table's column order, after its name.
1047fn stat_texts(dist_analysis: &DistributionAnalysis) -> [String; 8] {
1048    let characteristics = &dist_analysis.characteristics;
1049    let outliers = if dist_analysis.outliers.total_count > 0 {
1050        format!(
1051            "{} ({:.1}%)",
1052            dist_analysis.outliers.total_count, dist_analysis.outliers.percentage
1053        )
1054    } else {
1055        "0 (0.0%)".to_string()
1056    };
1057    [
1058        dist_analysis.distribution_type.to_string(),
1059        verdict_pvalue(dist_analysis),
1060        characteristics
1061            .shapiro_wilk_stat
1062            .map(|s| format!("{:.3}", s))
1063            .unwrap_or_else(|| "N/A".to_string()),
1064        characteristics
1065            .shapiro_wilk_pvalue
1066            .map(format_pvalue)
1067            .unwrap_or_else(|| "N/A".to_string()),
1068        format!("{:.4}", characteristics.coefficient_of_variation),
1069        outliers,
1070        format_num(characteristics.skewness),
1071        format_num(characteristics.kurtosis),
1072    ]
1073}
1074
1075/// The column the cursor's rail sits in, kept whether or not the table has focus
1076/// so focus arriving moves nothing.
1077const RAIL_WIDTH: u16 = 1;
1078
1079/// The rail beside the cursor's row: accented while the table has focus, dimmed while
1080/// the tool list does (a tint alone vanishes on 16 colors).
1081fn cursor_rail(focused: bool, theme: &Theme) -> Span<'static> {
1082    Span::styled(crate::glyphs::get().rail, rail_style(focused, theme))
1083}
1084
1085/// The rail's color: the accent with focus, dimmed without.
1086pub(crate) fn rail_style(focused: bool, theme: &Theme) -> Style {
1087    Style::default().fg(if focused {
1088        theme.accent()
1089    } else {
1090        theme.dimmed()
1091    })
1092}
1093
1094/// The row the cursor is on: the tint while the table has focus; without it,
1095/// only the dimmed rail marks it.
1096fn cursor_style(focused: bool, theme: &Theme) -> Style {
1097    if focused {
1098        theme.highlight_style()
1099    } else {
1100        Style::default()
1101    }
1102}
1103
1104/// The mark that counts statistics hidden to the right, as the data table's does.
1105fn more_mark(hidden: usize) -> String {
1106    format!(" +{hidden} {}", crate::glyphs::get().arrow_right)
1107}
1108
1109/// Which statistics fit beside the locked name column, as `start..end` from the scroll
1110/// offset. Sets the scroll's `max` to the first start showing the last statistic and
1111/// clamps to it, so a press past the end does nothing and the first press back moves.
1112/// Leaves room for the count mark when statistics are cut off right.
1113fn stat_window(
1114    widths: &[u16],
1115    available: u16,
1116    spacing: u16,
1117    columns: &mut ColumnScroll,
1118) -> (usize, usize) {
1119    let n = widths.len();
1120    if n == 0 {
1121        *columns = ColumnScroll::default();
1122        return (0, 0);
1123    }
1124    let fits = |from: usize, room: u16| {
1125        let mut used = 0u16;
1126        let mut count = 0usize;
1127        for width in &widths[from..] {
1128            let needed = width + if count > 0 { spacing } else { 0 };
1129            if used + needed > room {
1130                break;
1131            }
1132            used += needed;
1133            count += 1;
1134        }
1135        count.max(1)
1136    };
1137    let mark = crate::glyphs::display_width(&more_mark(n)) as u16;
1138    let shown = |from: usize| {
1139        let all = fits(from, available);
1140        if from + all >= n {
1141            all
1142        } else {
1143            fits(from, available.saturating_sub(mark))
1144        }
1145    };
1146    let max = (0..n)
1147        .find(|&from| from + shown(from) >= n)
1148        .unwrap_or(n - 1);
1149    columns.max = max;
1150    columns.offset = columns.offset.min(max);
1151    let start = columns.offset;
1152    (start, (start + shown(start)).min(n))
1153}
1154
1155/// Mark statistics out of view: an arrow at the locked column header's end for the
1156/// left, the count at the header's right edge for the right.
1157fn draw_scroll_marks(
1158    area: Rect,
1159    buf: &mut Buffer,
1160    locked_width: u16,
1161    (start, end, total): (usize, usize, usize),
1162    theme: &Theme,
1163) {
1164    if area.height == 0 || area.width == 0 {
1165        return;
1166    }
1167    let style = header_style(theme.controls_bg(), theme.accent()).add_modifier(Modifier::BOLD);
1168    if start > 0 && locked_width > 0 && locked_width <= area.width {
1169        Paragraph::new(crate::glyphs::get().arrow_left)
1170            .style(style)
1171            .render(
1172                Rect {
1173                    x: area.x + locked_width - 1,
1174                    width: 1,
1175                    height: 1,
1176                    ..area
1177                },
1178                buf,
1179            );
1180    }
1181    if end < total {
1182        let mark = more_mark(total - end);
1183        let width = crate::glyphs::display_width(&mark) as u16;
1184        if width <= area.width {
1185            Paragraph::new(mark).style(style).render(
1186                Rect {
1187                    x: area.x + area.width - width,
1188                    width,
1189                    height: 1,
1190                    ..area
1191                },
1192                buf,
1193            );
1194        }
1195    }
1196}
1197
1198/// The matrix's cell cursor, and whether the matrix has the focus.
1199struct MatrixCursor {
1200    cell: Option<(usize, usize)>,
1201    focused: bool,
1202}
1203
1204fn render_correlation_matrix(
1205    shown: Option<Shown>,
1206    table_state: &mut TableState,
1207    cursor: MatrixCursor,
1208    columns: &mut ColumnScroll,
1209    area: Rect,
1210    buf: &mut Buffer,
1211    theme: &Theme,
1212) {
1213    let MatrixCursor {
1214        cell: selected_cell,
1215        focused,
1216    } = cursor;
1217    let (correlation_matrix, method) = match shown {
1218        Some(Shown { matrix, method }) => (matrix, method),
1219        None => {
1220            Paragraph::new("No correlation matrix available (need at least 2 numeric columns)")
1221                .centered()
1222                .render(area, buf);
1223            return;
1224        }
1225    };
1226
1227    if method == CorrelationMethod::Spearman && correlation_matrix.rank_correlations.is_none() {
1228        Paragraph::new(SPEARMAN_TOO_MANY)
1229            .centered()
1230            .render(area, buf);
1231        return;
1232    }
1233
1234    if correlation_matrix.columns.is_empty() {
1235        Paragraph::new("No numeric columns for correlation matrix")
1236            .centered()
1237            .render(area, buf);
1238        return;
1239    }
1240
1241    let n = correlation_matrix.columns.len();
1242
1243    let row_header_width = 20u16;
1244    let cell_width = 12u16; // Wide enough for "-0.999" and most names
1245    let column_spacing = 1u16; // Table widget adds 1 space between columns
1246
1247    let available_width = area
1248        .width
1249        .saturating_sub(row_header_width)
1250        .saturating_sub(column_spacing);
1251    let widths = vec![cell_width; n];
1252    let (mut start_col, mut end_col) =
1253        stat_window(&widths, available_width, column_spacing, columns);
1254    // Scroll to the selected cell, whatever moved it: a key, or a resize.
1255    if let Some((_, col)) = selected_cell {
1256        let col = col.min(n - 1);
1257        while col < start_col || (col >= end_col && columns.offset < columns.max) {
1258            columns.offset = if col < start_col {
1259                col
1260            } else {
1261                columns.offset + 1
1262            };
1263            (start_col, end_col) = stat_window(&widths, available_width, column_spacing, columns);
1264        }
1265    }
1266    let visible_cols = end_col - start_col;
1267
1268    let (selected_row, selected_col) = selected_cell.unwrap_or((n, n));
1269
1270    let header_row_style = header_style(theme.controls_bg(), theme.table_header());
1271    let dim_header_style = header_style(theme.controls_bg(), theme.table_header());
1272
1273    let mut header_cells = vec![Cell::from("")];
1274    for j in start_col..end_col {
1275        let col_name = &correlation_matrix.columns[j];
1276        let is_selected_col = selected_cell.is_some() && j == selected_col;
1277        let cell_style = if is_selected_col {
1278            dim_header_style
1279        } else {
1280            header_row_style
1281        };
1282        header_cells.push(Cell::from(col_name.as_str()).style(cell_style));
1283    }
1284
1285    let header_row = Row::new(header_cells).style(header_row_style);
1286
1287    // Data rows - only render visible rows (handled by TableState's visible_rows)
1288    // But we render all rows and let Table widget handle vertical scrolling
1289    let mut rows = Vec::new();
1290    for (i, col_name) in correlation_matrix.columns.iter().enumerate() {
1291        let is_selected_row = selected_cell.is_some() && i == selected_row;
1292
1293        // Row header cell - dim highlight if selected row
1294        let row_header_style = if is_selected_row {
1295            Style::default().bg(theme.surface())
1296        } else {
1297            Style::default()
1298        };
1299        let mut cells = vec![Cell::from(col_name.as_str()).style(row_header_style)];
1300
1301        for col_idx in start_col..end_col {
1302            let correlation = correlation_matrix.coefficient(method, i, col_idx);
1303            let text_color = get_correlation_color(correlation, theme);
1304
1305            let cell_text = if i == col_idx {
1306                "1.000".to_string()
1307            } else if correlation.is_nan() {
1308                "-".to_string()
1309            } else {
1310                format_coefficient(correlation, 3)
1311            };
1312
1313            let is_selected_cell =
1314                selected_cell.is_some() && i == selected_row && col_idx == selected_col;
1315            let is_in_selected_col = selected_cell.is_some() && col_idx == selected_col;
1316
1317            let cell_style = if is_selected_cell && focused {
1318                // The cell cursor, as the table draws its own.
1319                Style::default()
1320                    .fg(text_color)
1321                    .patch(theme.cell_cursor_style())
1322            } else if is_selected_cell {
1323                // The matrix without focus: its cell stays marked, quietly.
1324                Style::default()
1325                    .fg(text_color)
1326                    .patch(theme.column_cursor_style())
1327                    .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1328            } else if is_selected_row || is_in_selected_col {
1329                // Selected row or column: dim background with colored text
1330                Style::default().fg(text_color).bg(theme.surface())
1331            } else {
1332                // Normal cell: just text color
1333                Style::default().fg(text_color)
1334            };
1335
1336            cells.push(Cell::from(cell_text).style(cell_style));
1337        }
1338
1339        let row_style = if is_selected_row {
1340            Style::default().bg(theme.surface())
1341        } else {
1342            Style::default()
1343        };
1344
1345        rows.push(Row::new(cells).style(row_style));
1346    }
1347
1348    let mut constraints = vec![Constraint::Length(row_header_width)];
1349    for _ in 0..visible_cols {
1350        constraints.push(Constraint::Length(cell_width));
1351    }
1352
1353    let last_idx = constraints.len().saturating_sub(1);
1354    if visible_cols == n && constraints.len() > 1 {
1355        constraints[last_idx] = Constraint::Fill(1);
1356    }
1357
1358    let table = Table::new(rows, constraints)
1359        .header(header_row)
1360        .column_spacing(column_spacing);
1361
1362    StatefulWidget::render(table, area, buf, table_state);
1363    draw_scroll_marks(area, buf, row_header_width, (start_col, end_col, n), theme);
1364}
1365
1366fn get_correlation_color(correlation: f64, theme: &Theme) -> Color {
1367    let abs_corr = correlation.abs();
1368
1369    if abs_corr < 0.05 {
1370        // No correlation (close to 0) - dimmed
1371        theme.dimmed()
1372    } else if abs_corr < 0.3 {
1373        // Low correlation - normal text
1374        theme.text_primary()
1375    } else if correlation > 0.0 {
1376        // Positive correlation - keybind hints color (UI element, not chart)
1377        theme.chip_key()
1378    } else {
1379        // Negative correlation - error/warning color
1380        theme.outlier_marker()
1381    }
1382}
1383
1384/// What the family list shows: the column's fits, the family on the plots, and
1385/// the scale the histogram is drawn in.
1386struct SelectorConfig<'a> {
1387    dist: &'a DistributionAnalysis,
1388    selected: DistributionType,
1389    histogram_scale: HistogramScale,
1390    /// Log was asked for on values that cannot take it, so the histogram is linear.
1391    log_scale_unavailable: bool,
1392    theme: &'a Theme,
1393    ctx: &'a RenderContext,
1394}
1395
1396/// The families to compare with, one Surface right of the detail: each family and its
1397/// p-value, the plotted one on the rail, and the histogram scale on the last row.
1398fn render_distribution_selector(
1399    config: SelectorConfig,
1400    selector_state: &mut TableState,
1401    area: Rect,
1402    buf: &mut Buffer,
1403) {
1404    let SelectorConfig {
1405        dist,
1406        selected: selected_dist,
1407        histogram_scale,
1408        log_scale_unavailable,
1409        theme,
1410        ctx,
1411    } = config;
1412    // Tested families by p-value, then the ones that do not apply; the same order
1413    // the modal's ↑↓ walks.
1414    let distribution_scores: Vec<(DistributionType, Option<&FitOutcome>)> =
1415        crate::analysis::distribution_fit::listing_order(&dist.fits)
1416            .into_iter()
1417            .map(|family| (family, dist.fit(family)))
1418            .collect();
1419
1420    let selected_pos = distribution_scores
1421        .iter()
1422        .position(|(family, _)| *family == selected_dist)
1423        .unwrap_or(0);
1424    // Trust the cursor while it is on the list; place it only when it is unset or
1425    // has fallen off the end.
1426    match selector_state.selected() {
1427        Some(idx) if idx < distribution_scores.len() => {}
1428        _ => selector_state.select(Some(selected_pos)),
1429    }
1430    let selected = selector_state.selected().unwrap_or(0);
1431
1432    let content = Surface::new("Distribution").render(area, buf, ctx);
1433    if content.height < 3 || content.width < 8 {
1434        return;
1435    }
1436    let g = crate::glyphs::get();
1437    // The p-value column is as wide as its widest value, "<0.005" or "n/a".
1438    const PVALUE_WIDTH: u16 = 7;
1439    let name_width = content.width.saturating_sub(1 + PVALUE_WIDTH);
1440    let line = |rail: &str, name: &str, pvalue: &str| {
1441        format!(
1442            "{rail}{name:<w$}{pvalue:>p$}",
1443            name = crate::glyphs::fit(name, name_width as usize),
1444            w = name_width as usize,
1445            p = PVALUE_WIDTH as usize,
1446        )
1447    };
1448    let row = |y: u16| Rect {
1449        y,
1450        height: 1,
1451        ..content
1452    };
1453
1454    Paragraph::new(line(" ", "Name", "P-value"))
1455        .style(Style::default().fg(ctx.text_secondary))
1456        .render(row(content.y), buf);
1457
1458    // The last row is the scale; the list scrolls in what is between, and counts
1459    // what it cannot show rather than cutting a family in half.
1460    let scale_y = content.y + content.height - 1;
1461    let list_height = (content.height - 2) as usize;
1462    let total = distribution_scores.len();
1463    let (offset, shown) = list_window(selected, total, list_height);
1464    let below = total - offset - shown;
1465    if below > 0 && shown < list_height {
1466        Paragraph::new(format!(" {} {below} more", g.ellipsis))
1467            .style(Style::default().fg(ctx.dimmed))
1468            .render(row(content.y + 1 + shown as u16), buf);
1469    }
1470    for (i, (family, outcome)) in distribution_scores
1471        .iter()
1472        .enumerate()
1473        .skip(offset)
1474        .take(shown)
1475    {
1476        let y = content.y + 1 + (i - offset) as u16;
1477        // A family that does not apply has no p-value, and says so rather than
1478        // ranking a placeholder.
1479        let (p_text, p_style) = match outcome.and_then(|outcome| outcome.test()) {
1480            Some(test) => (format_fit_pvalue(test), pvalue_style(test.p_value, theme)),
1481            None => ("n/a".to_string(), Style::default().fg(ctx.dimmed)),
1482        };
1483        let is_cursor = i == selected;
1484        let name = crate::glyphs::fit(&family.to_string(), name_width as usize);
1485        let spans = vec![
1486            Span::styled(
1487                if is_cursor { g.rail } else { " " },
1488                Style::default().fg(ctx.accent),
1489            ),
1490            Span::styled(
1491                format!("{name:<w$}", w = name_width as usize),
1492                Style::default().fg(ctx.text_primary),
1493            ),
1494            Span::styled(format!("{p_text:>p$}", p = PVALUE_WIDTH as usize), p_style),
1495        ];
1496        let mut paragraph = Paragraph::new(Line::from(spans));
1497        if is_cursor {
1498            paragraph = paragraph.style(ctx.highlight_style());
1499        }
1500        paragraph.render(row(y), buf);
1501    }
1502
1503    // Log asked for on values that cannot take it falls back to linear, in the
1504    // warning color so the fallback is not mistaken for the choice.
1505    let (scale, scale_style) = match (histogram_scale, log_scale_unavailable) {
1506        (_, true) => ("Linear", Style::default().fg(ctx.warning)),
1507        (HistogramScale::Linear, false) => ("Linear", Style::default().fg(ctx.text_primary)),
1508        (HistogramScale::Log, false) => ("Log", Style::default().fg(ctx.text_primary)),
1509    };
1510    if scale_y > content.y + 1 {
1511        Paragraph::new(Line::from(vec![
1512            Span::styled(" Scale: ", Style::default().fg(ctx.label)),
1513            Span::styled(scale, scale_style),
1514        ]))
1515        .render(row(scale_y), buf);
1516    }
1517}
1518
1519/// What the Distribution detail's two plots draw: the Q-Q plot and the histogram.
1520#[derive(Clone, Copy)]
1521struct DistributionPlotConfig<'a> {
1522    dist: &'a DistributionAnalysis,
1523    dist_type: DistributionType,
1524    area: Rect,
1525    shared_y_axis_label_width: u16,
1526    theme: &'a Theme,
1527    unified_x_range: Option<(f64, f64)>,
1528    histogram_scale: HistogramScale,
1529    glyphs: &'a crate::glyphs::Glyphs,
1530    /// The column's numbers, on every value axis.
1531    values: &'a AxisNumbers,
1532    counts: &'a AxisNumbers,
1533}
1534
1535/// The family list's least width: the frame, the rail, "Exponential" and a p-value.
1536const SELECTOR_WIDTH: u16 = 24;
1537
1538/// Which of `total` items `rows` rows show around the cursor (first, count). With items
1539/// below, the last row counts them and the cursor never sits there.
1540fn list_window(selected: usize, total: usize, rows: usize) -> (usize, usize) {
1541    if total <= rows || rows == 0 {
1542        return (0, total.min(rows));
1543    }
1544    let room = rows.saturating_sub(1).max(1);
1545    let offset = selected.saturating_sub(room - 1);
1546    if offset + rows >= total {
1547        (total - rows, rows)
1548    } else {
1549        (offset, room)
1550    }
1551}
1552
1553/// The tool list's width beside a result: a third of the screen, at most 32.
1554pub(crate) fn sidebar_width(width: u16) -> u16 {
1555    32u16.min(width / 3)
1556}
1557
1558/// Where a tool's result goes: under the one-line header, left of the tool list.
1559/// The Sample form fills it before a tool's first run.
1560pub(crate) fn main_pane(area: Rect) -> Rect {
1561    Rect {
1562        y: area.y + 1,
1563        height: area.height.saturating_sub(1),
1564        width: area.width.saturating_sub(sidebar_width(area.width)),
1565        ..area
1566    }
1567}
1568
1569/// The Analysis Tools list, the same beside every tool: the cursor's rail and tint while
1570/// focused, the tool on screen accented.
1571pub(crate) fn render_sidebar(
1572    area: Rect,
1573    buf: &mut Buffer,
1574    sidebar_state: &mut TableState,
1575    selected_tool: Option<AnalysisTool>,
1576    focus: AnalysisFocus,
1577    theme: &Theme,
1578) {
1579    let tools = [
1580        ("Describe", AnalysisTool::Describe),
1581        ("Distribution Analysis", AnalysisTool::DistributionAnalysis),
1582        ("Correlation Matrix", AnalysisTool::CorrelationMatrix),
1583        ("Data Quality", AnalysisTool::DataQuality),
1584    ];
1585    // Built here rather than passed: Data Quality draws this list too, from its
1586    // theme.
1587    let ctx =
1588        RenderContext::from_theme_and_config(theme, 0, false, NumberFormatSettings::default());
1589    let content = Surface::new("Analysis Tools").render(area, buf, &ctx);
1590    let g = crate::glyphs::get();
1591    let list_focused = focus == AnalysisFocus::Sidebar;
1592    for (idx, (name, tool)) in tools.iter().enumerate().take(content.height as usize) {
1593        let is_cursor = list_focused && sidebar_state.selected() == Some(idx);
1594        let on_screen = selected_tool == Some(*tool);
1595        // The tool on screen is bold; the accent is the cursor's alone.
1596        let name_style = if on_screen {
1597            Style::default()
1598                .fg(ctx.text_primary)
1599                .add_modifier(Modifier::BOLD)
1600        } else {
1601            Style::default().fg(ctx.text_primary)
1602        };
1603        // The tool on screen keeps a dimmed rail while its pane has the focus.
1604        let rail = if is_cursor || (on_screen && !list_focused) {
1605            g.rail
1606        } else {
1607            " "
1608        };
1609        let mut line = Paragraph::new(Line::from(vec![
1610            Span::styled(rail, rail_style(is_cursor, theme)),
1611            // Cut with a mark on a narrow screen, never silently.
1612            Span::styled(
1613                crate::glyphs::fit(name, content.width.saturating_sub(1) as usize),
1614                name_style,
1615            ),
1616        ]));
1617        if is_cursor {
1618            line = line.style(ctx.highlight_style());
1619        }
1620        let row = Rect {
1621            y: content.y + idx as u16,
1622            height: 1,
1623            ..content
1624        };
1625        line.render(row, buf);
1626        crate::app::pointer::record(row, crate::app::pointer::Hit::Tool(idx));
1627    }
1628}
1629
1630fn render_distribution_histogram(config: DistributionPlotConfig, buf: &mut Buffer) {
1631    let DistributionPlotConfig {
1632        dist,
1633        dist_type,
1634        area,
1635        shared_y_axis_label_width,
1636        theme,
1637        unified_x_range,
1638        histogram_scale,
1639        glyphs: g,
1640        values,
1641        counts,
1642    } = config;
1643    let sorted_data = &dist.sorted_sample_values;
1644
1645    if sorted_data.is_empty() || sorted_data.len() < 3 {
1646        Paragraph::new("Insufficient data for histogram")
1647            .centered()
1648            .render(area, buf);
1649        return;
1650    }
1651
1652    let n = sorted_data.len();
1653
1654    let data_min = sorted_data[0];
1655    let data_max = sorted_data[n - 1];
1656    let data_range = data_max - data_min;
1657
1658    if data_range <= 0.0 {
1659        // Constant data: all values are the same
1660        Paragraph::new("Constant data: all values are identical")
1661            .centered()
1662            .render(area, buf);
1663        return;
1664    }
1665
1666    // The range the Q-Q plot shares, so both plots read the same values at the same
1667    // column.
1668    let (hist_min, hist_max) = unified_x_range.unwrap_or((data_min, data_max));
1669
1670    // Calculate dynamic number of bins based on available width
1671    // This ensures bars fill the horizontal space and look dense at all widths
1672
1673    let y_axis_gap = 1u16; // Minimal gap between labels and plot area (needed to prevent bars from extending outside)
1674    let total_y_axis_space = shared_y_axis_label_width + y_axis_gap;
1675
1676    // Bar width must match the Chart's plot area exactly: minus its y-axis labels and the
1677    // axis line.
1678    let available_width = area.width.saturating_sub(total_y_axis_space + 1);
1679    // One blank column between neighboring bars.
1680    let gap_width = 1u16;
1681
1682    // Aim for ~7-cell bars: num_bins = (available + gap) / (bar + gap).
1683    let target_bar_width = 7.0; // Target bar width in pixels
1684    let optimal_num_bins = ((available_width as f64 + gap_width as f64)
1685        / (target_bar_width + gap_width as f64)) as usize;
1686
1687    // Between 5 and 60 bins (more for ultrawide displays).
1688    let num_bins = optimal_num_bins.clamp(5, 60);
1689
1690    // Log-scale bins when chosen and the data (not the padded range) is positive.
1691    let all_data_positive = data_min > 0.0;
1692    // For log scale, ensure hist_min is positive (adjust if needed)
1693    let (log_hist_min, log_hist_max) =
1694        if matches!(histogram_scale, HistogramScale::Log) && all_data_positive {
1695            // Use actual data min/max for log scale to avoid issues with padding or theoretical bounds
1696            let actual_min = sorted_data[0];
1697            let actual_max = sorted_data[sorted_data.len() - 1];
1698            if actual_min > 0.0 {
1699                (actual_min, actual_max)
1700            } else {
1701                // Can't use log scale if data includes 0
1702                (hist_min, hist_max)
1703            }
1704        } else {
1705            (hist_min, hist_max)
1706        };
1707    let use_log_scale = matches!(histogram_scale, HistogramScale::Log)
1708        && all_data_positive
1709        && log_hist_min > 0.0
1710        && log_hist_max > log_hist_min;
1711
1712    // The fit's expected counts are drawn behind the bars, sampled densely enough
1713    // that braille renders them as a line.
1714    let histogram = dist.histogram(HistogramKey {
1715        family: dist_type,
1716        bins: num_bins,
1717        log: use_log_scale,
1718        range: if use_log_scale {
1719            (log_hist_min, log_hist_max)
1720        } else {
1721            (hist_min, hist_max)
1722        },
1723        samples: (available_width as usize * 15).clamp(1500, 10000),
1724    });
1725    let global_max = histogram.top;
1726
1727    // Use the shared label width calculated in the caller
1728    // This ensures both histogram and Q-Q plot use the same padding for alignment
1729    let y_axis_label_width = shared_y_axis_label_width;
1730
1731    // Each bin's bar on the 0-100 scale the curve and the count labels use. No value
1732    // or label: the axes say what a bar's height and place mean.
1733    let data_bars: Vec<Bar> = histogram
1734        .counts
1735        .iter()
1736        .map(|&data_count| {
1737            let data_height = ((data_count as f64 / global_max) * 100.0) as u64;
1738            Bar::default()
1739                .value(data_height)
1740                .text_value(String::new())
1741                .style(Style::default().fg(theme.chart_1()))
1742        })
1743        .collect();
1744
1745    // Labels padded to the width shared with the Q-Q plot; bars stand on a 0-100 scale,
1746    // labeled in counts.
1747    let label_width = y_axis_label_width as usize;
1748    let count_axis = AxisSpec::numbers_as([0.0, 100.0], counts, "Counts", move |v| {
1749        v * global_max / 100.0
1750    });
1751    let x_axis = if use_log_scale {
1752        AxisSpec::numbers_as([log_hist_min.ln(), log_hist_max.ln()], values, "", f64::exp)
1753    } else {
1754        AxisSpec::numbers([hist_min, hist_max], values, "")
1755    };
1756    let axes = distribution_axes(theme, x_axis, count_axis.padded(label_width), g.plot.line);
1757    let block = distribution_block(format!("Histogram vs {dist_type}"));
1758    let chart_area = block.inner(area);
1759
1760    // Exactly the overlay's plot area: bar `i` starts where bin `i` does.
1761    let bar_plot_area = axes.frame(chart_area).graph;
1762
1763    // Bin `i` spans the plot columns its values map to (as labels and curve map them),
1764    // less a gap, so bars reach the right end.
1765    let plot_width = bar_plot_area.width as usize;
1766    let bin_edge = |i: usize| ((2 * i * plot_width + num_bins) / (2 * num_bins)) as u16;
1767    let bar_charts: Vec<(Rect, BarChart)> = data_bars
1768        .into_iter()
1769        .enumerate()
1770        .filter_map(|(i, bar)| {
1771            let (start, end) = (bin_edge(i), bin_edge(i + 1));
1772            let span = end - start;
1773            let width = if i + 1 < num_bins && span > gap_width {
1774                span - gap_width
1775            } else {
1776                span
1777            };
1778            let rect = Rect {
1779                x: bar_plot_area.x + start,
1780                width,
1781                ..bar_plot_area
1782            };
1783            let chart = BarChart::default()
1784                .data(BarGroup::default().bars(&[bar]))
1785                // The curve's 0-100 scale, not the tallest bar's, so the curve measures against bars.
1786                .max(100)
1787                .bar_set(g.plot.column_set())
1788                .bar_width(width)
1789                .bar_gap(0);
1790            (width > 0).then_some((rect, chart))
1791        })
1792        .collect();
1793
1794    // Dense points in the line mark read as a continuous curve.
1795    let marker = g.plot.line;
1796
1797    let theory_dataset = Dataset::default()
1798        .name("") // Empty name to prevent legend from appearing
1799        .marker(marker)
1800        .graph_type(GraphType::Scatter)
1801        .style(Style::default().fg(theme.dimmed()))
1802        .data(&histogram.curve);
1803
1804    let theory_chart = Chart::new(vec![theory_dataset])
1805        .hidden_legend_constraints((Constraint::Length(0), Constraint::Length(0)));
1806
1807    // The bars, then the chart overlaid from its own buffer except where the curve
1808    // crosses a bar: drawn directly, braille cells would notch bars; drawn under, blank
1809    // bar cells would erase the curve and title.
1810    for (rect, chart) in bar_charts {
1811        chart.render(rect, buf);
1812    }
1813    let mut overlay = Buffer::empty(area);
1814    block.render(area, &mut overlay);
1815    axes.render(theory_chart, chart_area, &mut overlay, g);
1816    let is_bar = |symbol: &str| g.plot.column_eighths.contains(&symbol);
1817    for y in area.top()..area.bottom() {
1818        for x in area.left()..area.right() {
1819            let cell = &overlay[(x, y)];
1820            let symbol = cell.symbol();
1821            if symbol == " " || (PlotMarks::is_mark(marker, symbol) && is_bar(buf[(x, y)].symbol()))
1822            {
1823                continue;
1824            }
1825            buf[(x, y)] = cell.clone();
1826        }
1827    }
1828}
1829
1830fn render_qq_plot(config: DistributionPlotConfig, buf: &mut Buffer) {
1831    let DistributionPlotConfig {
1832        dist,
1833        dist_type,
1834        area,
1835        shared_y_axis_label_width,
1836        theme,
1837        unified_x_range,
1838        glyphs: g,
1839        values,
1840        ..
1841    } = config;
1842    let sorted_data = &dist.sorted_sample_values;
1843
1844    if sorted_data.is_empty() || sorted_data.len() < 3 {
1845        Paragraph::new("Insufficient data for Q-Q plot (need at least 3 points)")
1846            .centered()
1847            .render(area, buf);
1848        return;
1849    }
1850
1851    // The fit's quantiles at each plotting position, computed with the fit.
1852    let Some(theoretical) = dist.qq(dist_type) else {
1853        let reason = match dist.fit(dist_type) {
1854            Some(FitOutcome::NotApplicable(reason)) => format!("{dist_type} {reason}"),
1855            _ => format!("{dist_type} was not fitted"),
1856        };
1857        Paragraph::new(reason)
1858            .centered()
1859            .wrap(ratatui::widgets::Wrap { trim: true })
1860            .render(area, buf);
1861        return;
1862    };
1863    let qq_data: Vec<(f64, f64)> = theoretical
1864        .iter()
1865        .zip(sorted_data)
1866        .map(|(t, d)| (*t, *d))
1867        .filter(|(t, _)| t.is_finite())
1868        .collect();
1869    if qq_data.len() < 3 {
1870        Paragraph::new("Insufficient data for Q-Q plot (need at least 3 points)")
1871            .centered()
1872            .render(area, buf);
1873        return;
1874    }
1875    let n = qq_data.len();
1876
1877    // Axis ranges: X theoretical (inverse CDF of percentiles), Y the sorted sample as is.
1878    let theory_min = qq_data
1879        .iter()
1880        .map(|(t, _)| *t)
1881        .fold(f64::INFINITY, f64::min);
1882    let theory_max = qq_data
1883        .iter()
1884        .map(|(t, _)| *t)
1885        .fold(f64::NEG_INFINITY, f64::max);
1886    let theory_range = theory_max - theory_min;
1887
1888    let data_min = qq_data
1889        .iter()
1890        .map(|(_, d)| *d)
1891        .fold(f64::INFINITY, f64::min);
1892    let data_max = qq_data
1893        .iter()
1894        .map(|(_, d)| *d)
1895        .fold(f64::NEG_INFINITY, f64::max);
1896    let data_range = data_max - data_min;
1897
1898    // Only require data_range > 0 (allow plotting even if theoretical range is small/zero)
1899    // This handles cases where distribution doesn't match (e.g., negative data vs strictly positive distribution)
1900    if data_range <= 0.0 {
1901        Paragraph::new("Insufficient data range for Q-Q plot")
1902            .centered()
1903            .render(area, buf);
1904        return;
1905    }
1906
1907    // Use unified X-axis range if provided for visual alignment with histogram
1908    // Otherwise, handle case where all theoretical quantiles are the same (theory_range = 0)
1909    let (theory_min_plot, theory_max_plot) =
1910        if let Some((unified_min, unified_max)) = unified_x_range {
1911            (unified_min, unified_max)
1912        } else if theory_range <= 0.0 || !theory_min.is_finite() || !theory_max.is_finite() {
1913            // Fallback: use data range (no padding)
1914            (data_min, data_max)
1915        } else {
1916            // Use theoretical range, but clamp to data range to keep charts in sync
1917            (theory_min.max(data_min), theory_max.min(data_max))
1918        };
1919
1920    // Create robust reference line through Q1 and Q3 quartiles
1921    // This works even when domains don't overlap (e.g., negative data vs positive distribution)
1922    let q1_idx = (n as f64 * 0.25).floor() as usize;
1923    let q3_idx = (n as f64 * 0.75).floor() as usize;
1924    let q1_idx = q1_idx.min(n - 1);
1925    let q3_idx = q3_idx.min(n - 1);
1926
1927    let (theory_q1, data_q1) = if q1_idx < qq_data.len() {
1928        qq_data[q1_idx]
1929    } else {
1930        qq_data[0]
1931    };
1932    let (theory_q3, data_q3) = if q3_idx < qq_data.len() {
1933        qq_data[q3_idx]
1934    } else {
1935        qq_data[qq_data.len() - 1]
1936    };
1937
1938    // Calculate robust reference line through (theory_q1, data_q1) and (theory_q3, data_q3)
1939    // This works even when domains don't overlap (e.g., negative data vs positive distribution)
1940    let theory_diff = theory_q3 - theory_q1;
1941    let reference_line = if theory_diff.abs() > 1e-10 {
1942        // Normal case: calculate slope and extend line to cover plot range (no padding)
1943        let slope = (data_q3 - data_q1) / theory_diff;
1944        let x_start = theory_min_plot;
1945        let x_end = theory_max_plot;
1946        let y_start = slope * (x_start - theory_q1) + data_q1;
1947        let y_end = slope * (x_end - theory_q1) + data_q1;
1948        vec![(x_start, y_start), (x_end, y_end)]
1949    } else {
1950        // Degenerate case: all theoretical quantiles are the same (theory_range ≈ 0)
1951        // Use horizontal line through data median to show the mismatch (no padding)
1952        let y_median = (data_q1 + data_q3) / 2.0;
1953        vec![(theory_min_plot, y_median), (theory_max_plot, y_median)]
1954    };
1955
1956    let marker = if qq_data.len() > 100 {
1957        g.plot.line
1958    } else {
1959        g.plot.point
1960    };
1961
1962    let datasets = vec![
1963        // Diagonal reference line
1964        Dataset::default()
1965            .name("") // Empty name to hide from legend
1966            .marker(marker)
1967            .style(Style::default().fg(theme.dimmed()))
1968            .graph_type(GraphType::Line)
1969            .data(&reference_line),
1970        // Q-Q plot data points
1971        Dataset::default()
1972            .name("") // Empty name to hide from legend
1973            .marker(marker)
1974            .style(Style::default().fg(theme.chart_1()))
1975            .graph_type(GraphType::Scatter)
1976            .data(&qq_data),
1977    ];
1978
1979    // Padded to the width shared with the histogram, so both plots start in the same
1980    // column.
1981    let label_width = shared_y_axis_label_width as usize;
1982    let axes = distribution_axes(
1983        theme,
1984        AxisSpec::numbers(
1985            [theory_min_plot, theory_max_plot],
1986            values,
1987            "Theoretical Values",
1988        ),
1989        AxisSpec::numbers([data_min, data_max], values, "Data Values").padded(label_width),
1990        marker,
1991    );
1992    let block = distribution_block(format!("Q-Q Plot vs {dist_type}"));
1993    let chart_area = block.inner(area);
1994    block.render(area, buf);
1995    let chart = Chart::new(datasets)
1996        .hidden_legend_constraints((Constraint::Length(0), Constraint::Length(0)));
1997    axes.render(chart, chart_area, buf, g);
1998}
1999
2000/// A Distribution plot's frame: its title centered above, a cell of air on the left.
2001fn distribution_block<'a>(title: String) -> Block<'a> {
2002    Block::default()
2003        .title(title)
2004        .title_style(ratatui::style::Style::reset())
2005        .title_alignment(ratatui::layout::Alignment::Center)
2006        .padding(ratatui::widgets::Padding::left(1))
2007}
2008
2009fn distribution_axes<'a>(
2010    theme: &Theme,
2011    x: AxisSpec<'a>,
2012    y: AxisSpec<'a>,
2013    marker: ratatui::symbols::Marker,
2014) -> PlotAxes<'a> {
2015    let secondary = Style::default().fg(theme.text_secondary());
2016    PlotAxes {
2017        titles: Style::default(),
2018        ..PlotAxes::new(x, y, secondary, marker)
2019    }
2020}
2021
2022/// The detail's key figures, label and value: the fit found, Shapiro-Francia,
2023/// skew, kurtosis, median, mean, std and CV.
2024fn condensed_statistics(dist: &DistributionAnalysis) -> Vec<(&'static str, String)> {
2025    let chars = &dist.characteristics;
2026    // What the values were found to fit, before anything about the family the plots
2027    // compare them with: choosing a family below is a comparison, not a finding.
2028    let mut figures = vec![(
2029        "Fit",
2030        match dist.distribution_type {
2031            DistributionType::Unknown | DistributionType::Constant => {
2032                dist.distribution_type.to_string()
2033            }
2034            family => format!("{family} (p {})", verdict_pvalue(dist)),
2035        },
2036    )];
2037    if let (Some(sw_stat), Some(sw_p)) = (chars.shapiro_wilk_stat, chars.shapiro_wilk_pvalue) {
2038        figures.push((
2039            "SF",
2040            if sw_p < 0.001 {
2041                format!("{sw_stat:.3} (p<0.001)")
2042            } else {
2043                format!("{sw_stat:.3} (p={sw_p:.3})")
2044            },
2045        ));
2046    }
2047    figures.extend([
2048        ("Skew", format!("{:.2}", chars.skewness)),
2049        ("Kurt", format!("{:.2}", chars.kurtosis)),
2050        ("Median", format!("{:.2}", dist.percentiles.p50)),
2051        ("Mean", format!("{:.2}", chars.mean)),
2052        ("Std", format!("{:.2}", chars.std_dev)),
2053        ("CV", format!("{:.3}", chars.coefficient_of_variation)),
2054    ]);
2055    figures
2056}
2057
2058/// The figures packed into lines of `width`, never splitting a label from its
2059/// value, so a narrow terminal wraps them rather than cutting the last ones off.
2060fn condensed_statistics_lines(
2061    figures: &[(&'static str, String)],
2062    width: u16,
2063    theme: &Theme,
2064) -> Vec<Line<'static>> {
2065    let style = Style::default().fg(theme.text_primary());
2066    let mut lines = Vec::new();
2067    let mut spans = Vec::new();
2068    let mut used = 0usize;
2069    for (label, value) in figures {
2070        let figure = format!("{label}: {value}");
2071        let w = crate::glyphs::display_width(&figure);
2072        if used > 0 && used + 1 + w > width as usize {
2073            lines.push(Line::from(std::mem::take(&mut spans)));
2074            used = 0;
2075        }
2076        if used > 0 {
2077            spans.push(Span::styled(" ", style));
2078            used += 1;
2079        }
2080        spans.push(Span::styled(figure, style));
2081        used += w;
2082    }
2083    if !spans.is_empty() {
2084        lines.push(Line::from(spans));
2085    }
2086    lines
2087}
2088
2089#[cfg(test)]
2090mod tests;