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 pub number_format: &'a NumberFormatSettings,
42 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 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 let sidebar_width = sidebar_width(area.width);
119
120 let layout = Layout::default()
122 .direction(Direction::Vertical)
123 .constraints([
124 Constraint::Length(1), Constraint::Fill(1), ])
127 .split(area);
128
129 let tool_name = match self.selected_tool {
131 Some(AnalysisTool::Describe) => "Describe".to_string(),
132 Some(AnalysisTool::DistributionAnalysis) => "Distribution Analysis".to_string(),
133 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 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), Constraint::Length(sidebar_width), ])
165 .split(layout[1]);
166
167 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 }
240 }
241
242 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 }
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 let layout = Layout::default()
264 .direction(Direction::Vertical)
265 .constraints([
266 Constraint::Length(1), Constraint::Fill(1), ])
269 .split(area);
270
271 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 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 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 let charts_layout = Layout::default()
305 .direction(Direction::Vertical)
306 .constraints([
307 Constraint::Percentage(52), Constraint::Length(1), Constraint::Percentage(47), ])
311 .split(content_layout[0]);
312
313 let chart_padding = 1u16; let right_padding_extra = 1u16; let top_padding_extra = 1u16; let qq_plot_area = Rect::new(
317 charts_layout[0].left() + chart_padding,
318 charts_layout[0].top() + chart_padding + top_padding_extra, charts_layout[0]
320 .width
321 .saturating_sub(chart_padding) .saturating_sub(right_padding_extra), charts_layout[0]
324 .height
325 .saturating_sub(chart_padding * 2)
326 .saturating_sub(top_padding_extra), );
328 let histogram_area = Rect::new(
329 charts_layout[2].left() + chart_padding,
330 charts_layout[2].top() + chart_padding + top_padding_extra, charts_layout[2]
332 .width
333 .saturating_sub(chart_padding) .saturating_sub(right_padding_extra), charts_layout[2]
336 .height
337 .saturating_sub(chart_padding * 2)
338 .saturating_sub(top_padding_extra), );
340
341 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 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 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 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 } 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 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#[derive(Clone, Copy)]
468struct Shown<'a> {
469 matrix: &'a crate::analysis::statistics::CorrelationMatrix,
470 method: CorrelationMethod,
471}
472
473const SPEARMAN_TOO_MANY: &str = "Too many values to rank for Spearman; s chooses a smaller sample";
475
476fn 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
485fn 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
497fn 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
522fn 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 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
593struct 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 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 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 let mut min_col_widths: Vec<u16> = stat_display_names
646 .iter()
647 .map(|name| crate::glyphs::display_width(name) as u16) .collect();
649
650 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 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 }
661 }
662
663 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); let column_spacing = table_cell_padding;
675 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
734fn 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
772fn 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
790fn format_pvalue(p: f64) -> String {
792 if p < 0.001 {
793 "<0.001".to_string()
794 } else {
795 format!("{:.3}", p)
796 }
797}
798
799fn 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
815fn 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
825fn 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
836pub(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 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 let mut min_col_widths: Vec<u16> = column_names
878 .iter()
879 .map(|name| crate::glyphs::display_width(name) as u16) .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 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 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 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 let outlier_style = if dist_analysis.outliers.percentage > 20.0 {
939 Style::default().fg(theme.outlier_marker())
941 } else if dist_analysis.outliers.percentage > 5.0 {
942 Style::default().fg(theme.distribution_skewed())
944 } else {
945 Style::default()
947 };
948
949 let skewness_value = dist_analysis.characteristics.skewness.abs();
950 let kurtosis_value = dist_analysis.characteristics.kurtosis;
951
952 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 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 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 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 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
1046fn 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
1075const RAIL_WIDTH: u16 = 1;
1078
1079fn cursor_rail(focused: bool, theme: &Theme) -> Span<'static> {
1082 Span::styled(crate::glyphs::get().rail, rail_style(focused, theme))
1083}
1084
1085pub(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
1094fn cursor_style(focused: bool, theme: &Theme) -> Style {
1097 if focused {
1098 theme.highlight_style()
1099 } else {
1100 Style::default()
1101 }
1102}
1103
1104fn more_mark(hidden: usize) -> String {
1106 format!(" +{hidden} {}", crate::glyphs::get().arrow_right)
1107}
1108
1109fn 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
1155fn 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
1198struct 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; let column_spacing = 1u16; 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 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 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 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 Style::default()
1320 .fg(text_color)
1321 .patch(theme.cell_cursor_style())
1322 } else if is_selected_cell {
1323 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 Style::default().fg(text_color).bg(theme.surface())
1331 } else {
1332 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 theme.dimmed()
1372 } else if abs_corr < 0.3 {
1373 theme.text_primary()
1375 } else if correlation > 0.0 {
1376 theme.chip_key()
1378 } else {
1379 theme.outlier_marker()
1381 }
1382}
1383
1384struct SelectorConfig<'a> {
1387 dist: &'a DistributionAnalysis,
1388 selected: DistributionType,
1389 histogram_scale: HistogramScale,
1390 log_scale_unavailable: bool,
1392 theme: &'a Theme,
1393 ctx: &'a RenderContext,
1394}
1395
1396fn 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 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 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 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 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 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 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#[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 values: &'a AxisNumbers,
1532 counts: &'a AxisNumbers,
1533}
1534
1535const SELECTOR_WIDTH: u16 = 24;
1537
1538fn 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
1553pub(crate) fn sidebar_width(width: u16) -> u16 {
1555 32u16.min(width / 3)
1556}
1557
1558pub(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
1569pub(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 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 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 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 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 Paragraph::new("Constant data: all values are identical")
1661 .centered()
1662 .render(area, buf);
1663 return;
1664 }
1665
1666 let (hist_min, hist_max) = unified_x_range.unwrap_or((data_min, data_max));
1669
1670 let y_axis_gap = 1u16; let total_y_axis_space = shared_y_axis_label_width + y_axis_gap;
1675
1676 let available_width = area.width.saturating_sub(total_y_axis_space + 1);
1679 let gap_width = 1u16;
1681
1682 let target_bar_width = 7.0; let optimal_num_bins = ((available_width as f64 + gap_width as f64)
1685 / (target_bar_width + gap_width as f64)) as usize;
1686
1687 let num_bins = optimal_num_bins.clamp(5, 60);
1689
1690 let all_data_positive = data_min > 0.0;
1692 let (log_hist_min, log_hist_max) =
1694 if matches!(histogram_scale, HistogramScale::Log) && all_data_positive {
1695 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 (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 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 let y_axis_label_width = shared_y_axis_label_width;
1730
1731 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 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 let bar_plot_area = axes.frame(chart_area).graph;
1762
1763 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 .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 let marker = g.plot.line;
1796
1797 let theory_dataset = Dataset::default()
1798 .name("") .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 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 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 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 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 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 (data_min, data_max)
1915 } else {
1916 (theory_min.max(data_min), theory_max.min(data_max))
1918 };
1919
1920 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 let theory_diff = theory_q3 - theory_q1;
1941 let reference_line = if theory_diff.abs() > 1e-10 {
1942 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 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 Dataset::default()
1965 .name("") .marker(marker)
1967 .style(Style::default().fg(theme.dimmed()))
1968 .graph_type(GraphType::Line)
1969 .data(&reference_line),
1970 Dataset::default()
1972 .name("") .marker(marker)
1974 .style(Style::default().fg(theme.chart_1()))
1975 .graph_type(GraphType::Scatter)
1976 .data(&qq_data),
1977 ];
1978
1979 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
2000fn 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
2022fn condensed_statistics(dist: &DistributionAnalysis) -> Vec<(&'static str, String)> {
2025 let chars = &dist.characteristics;
2026 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
2058fn 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;