Skip to main content

datui_lib/
analysis_modal.rs

1use crate::statistics::{AnalysisResults, DistributionType};
2use ratatui::widgets::TableState;
3
4#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
5pub enum AnalysisView {
6    #[default]
7    Main, // Main tool view
8    DistributionDetail, // Full-screen distribution detail view
9    CorrelationDetail,  // Full-screen correlation pair detail view
10}
11
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
13pub enum AnalysisTool {
14    #[default]
15    Describe, // Column describe table
16    DistributionAnalysis, // Distribution analysis table
17    CorrelationMatrix,    // Correlation matrix
18}
19
20/// Progress state for the analysis progress overlay (display only).
21#[derive(Debug, Clone)]
22pub struct AnalysisProgress {
23    pub phase: String,
24    pub current: usize,
25    pub total: usize,
26}
27
28#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
29pub enum AnalysisFocus {
30    #[default]
31    Main, // Focus on main area (tool view)
32    Sidebar,              // Focus on sidebar (tool list)
33    DistributionSelector, // Focus on distribution selector in detail view
34}
35
36#[derive(Default)]
37pub struct AnalysisModal {
38    pub active: bool,
39    pub scroll_position: usize,
40    pub selected_column: Option<usize>,
41    pub describe_column_offset: usize, // For horizontal scrolling in describe table
42    pub distribution_column_offset: usize, // For horizontal scrolling in distribution table
43    pub correlation_column_offset: usize, // For horizontal scrolling in correlation matrix
44    pub random_seed: u64,
45    pub table_state: TableState,              // For describe table
46    pub distribution_table_state: TableState, // For distribution table
47    pub correlation_table_state: TableState,  // For correlation matrix
48    pub sidebar_state: TableState,            // For sidebar tool list
49    /// Cached results per tool; each tool computes and stores its own state independently.
50    pub describe_results: Option<AnalysisResults>,
51    pub distribution_results: Option<AnalysisResults>,
52    pub correlation_results: Option<AnalysisResults>,
53    /// When Some, show progress overlay (phase, current/total); in-progress data lives in App.
54    pub computing: Option<AnalysisProgress>,
55    pub show_help: bool,
56    pub view: AnalysisView,
57    pub focus: AnalysisFocus,
58    /// None = no tool selected yet (show instructions); Some(tool) = user chose a tool (may be computing or showing results).
59    pub selected_tool: Option<AnalysisTool>,
60    pub selected_distribution: Option<usize>, // Selected row in distribution table
61    pub selected_correlation: Option<(usize, usize)>, // Selected cell in correlation matrix (row, col)
62    pub detail_section: usize, // Current section in detail view (0=Characteristics, 1=Outliers, 2=Percentiles)
63    pub selected_theoretical_distribution: DistributionType, // Selected theoretical distribution for Q-Q plot
64    pub distribution_selector_state: TableState,             // For distribution selector list
65    pub histogram_scale: HistogramScale,                     // Scale for histogram (linear or log)
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum HistogramScale {
70    #[default]
71    Linear,
72    Log,
73}
74
75impl AnalysisModal {
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    pub fn open(&mut self) {
81        self.active = true;
82        self.scroll_position = 0;
83        self.selected_column = None;
84        self.describe_column_offset = 0;
85        self.distribution_column_offset = 0;
86        self.correlation_column_offset = 0;
87        self.table_state.select(Some(0));
88        self.distribution_table_state.select(Some(0));
89        self.correlation_table_state.select(Some(0));
90        self.sidebar_state.select(Some(0)); // Highlight first tool; user must press Enter to select
91        self.view = AnalysisView::Main;
92        self.focus = AnalysisFocus::Sidebar; // Sidebar focused by default when no tool selected
93        self.selected_tool = None; // No tool until user selects from sidebar
94        self.selected_distribution = Some(0);
95        self.selected_correlation = Some((0, 0));
96        self.detail_section = 0;
97        self.computing = None;
98        self.describe_results = None;
99        self.distribution_results = None;
100        self.correlation_results = None;
101        // Generate initial random seed (use 0 if system time is before UNIX_EPOCH)
102        self.random_seed = std::time::SystemTime::now()
103            .duration_since(std::time::UNIX_EPOCH)
104            .unwrap_or_default()
105            .as_nanos() as u64;
106    }
107
108    pub fn close(&mut self) {
109        self.active = false;
110        self.scroll_position = 0;
111        self.selected_column = None;
112        self.describe_column_offset = 0;
113        self.distribution_column_offset = 0;
114        self.correlation_column_offset = 0;
115        self.view = AnalysisView::Main;
116        self.focus = AnalysisFocus::Main;
117        self.selected_tool = None;
118        self.selected_distribution = None;
119        self.selected_correlation = None;
120        self.detail_section = 0;
121        self.computing = None;
122        self.describe_results = None;
123        self.distribution_results = None;
124        self.correlation_results = None;
125    }
126
127    /// Returns the cached results for the currently selected tool, if any.
128    pub fn current_results(&self) -> Option<&AnalysisResults> {
129        match self.selected_tool {
130            Some(AnalysisTool::Describe) => self.describe_results.as_ref(),
131            Some(AnalysisTool::DistributionAnalysis) => self.distribution_results.as_ref(),
132            Some(AnalysisTool::CorrelationMatrix) => self.correlation_results.as_ref(),
133            None => None,
134        }
135    }
136
137    pub fn switch_focus(&mut self) {
138        if self.view == AnalysisView::DistributionDetail {
139            self.focus = match self.focus {
140                AnalysisFocus::Main => AnalysisFocus::DistributionSelector,
141                AnalysisFocus::DistributionSelector => AnalysisFocus::Main,
142                _ => AnalysisFocus::DistributionSelector,
143            };
144        } else {
145            self.focus = match self.focus {
146                AnalysisFocus::Main => AnalysisFocus::Sidebar,
147                AnalysisFocus::Sidebar => AnalysisFocus::Main,
148                _ => AnalysisFocus::Main,
149            };
150        }
151    }
152
153    pub fn select_tool(&mut self) {
154        if let Some(idx) = self.sidebar_state.selected() {
155            self.selected_tool = Some(match idx {
156                0 => AnalysisTool::Describe,
157                1 => AnalysisTool::DistributionAnalysis,
158                2 => AnalysisTool::CorrelationMatrix,
159                _ => AnalysisTool::Describe,
160            });
161            self.focus = AnalysisFocus::Main;
162        }
163    }
164
165    pub fn next_tool(&mut self) {
166        if let Some(current) = self.sidebar_state.selected() {
167            let next = (current + 1).min(2);
168            self.sidebar_state.select(Some(next));
169        }
170    }
171
172    pub fn previous_tool(&mut self) {
173        if let Some(current) = self.sidebar_state.selected() {
174            if current > 0 {
175                self.sidebar_state.select(Some(current - 1));
176            }
177        }
178    }
179
180    pub fn open_distribution_detail(&mut self) {
181        if self.focus == AnalysisFocus::Main
182            && self.selected_tool == Some(AnalysisTool::DistributionAnalysis)
183        {
184            if let Some(idx) = self.distribution_table_state.selected() {
185                if let Some(results) = &self.distribution_results {
186                    if let Some(dist_analysis) = results.distribution_analyses.get(idx) {
187                        self.selected_theoretical_distribution = dist_analysis.distribution_type;
188                    }
189                }
190                self.view = AnalysisView::DistributionDetail;
191                self.detail_section = 0;
192                self.focus = AnalysisFocus::DistributionSelector;
193                if self.selected_theoretical_distribution == DistributionType::Unknown {
194                    self.selected_theoretical_distribution = DistributionType::Normal;
195                }
196                self.distribution_selector_state.select(None);
197            }
198        }
199    }
200
201    pub fn open_correlation_detail(&mut self) {
202        if self.focus == AnalysisFocus::Main
203            && self.selected_tool == Some(AnalysisTool::CorrelationMatrix)
204        {
205            if let Some((row, col)) = self.selected_correlation {
206                if row != col {
207                    self.view = AnalysisView::CorrelationDetail;
208                }
209            }
210        }
211    }
212
213    pub fn close_detail(&mut self) {
214        self.view = AnalysisView::Main;
215        self.detail_section = 0;
216        self.focus = AnalysisFocus::Main;
217    }
218
219    pub fn next_detail_section(&mut self) {
220        self.detail_section = (self.detail_section + 1) % 3;
221    }
222
223    pub fn previous_detail_section(&mut self) {
224        self.detail_section = if self.detail_section == 0 {
225            2
226        } else {
227            self.detail_section - 1
228        };
229    }
230
231    pub fn scroll_left(&mut self) {
232        match self.selected_tool {
233            Some(AnalysisTool::Describe) => {
234                if self.describe_column_offset > 0 {
235                    self.describe_column_offset -= 1;
236                }
237            }
238            Some(AnalysisTool::DistributionAnalysis) => {
239                if self.distribution_column_offset > 0 {
240                    self.distribution_column_offset -= 1;
241                }
242            }
243            _ => {}
244        }
245    }
246
247    pub fn scroll_right(&mut self, max_columns: usize, visible_columns: usize) {
248        match self.selected_tool {
249            Some(AnalysisTool::Describe) => {
250                let offset = &mut self.describe_column_offset;
251                if *offset + visible_columns < max_columns
252                    && *offset < max_columns.saturating_sub(1)
253                {
254                    *offset += 1;
255                }
256            }
257            Some(AnalysisTool::DistributionAnalysis) => {
258                let offset = &mut self.distribution_column_offset;
259                if *offset + visible_columns < max_columns
260                    && *offset < max_columns.saturating_sub(1)
261                {
262                    *offset += 1;
263                }
264            }
265            _ => {}
266        }
267    }
268
269    pub fn recalculate(&mut self) {
270        self.random_seed = std::time::SystemTime::now()
271            .duration_since(std::time::UNIX_EPOCH)
272            .unwrap_or_default()
273            .as_nanos() as u64;
274    }
275
276    pub fn next_row(&mut self, max_rows: usize) {
277        if self.focus == AnalysisFocus::Sidebar {
278            self.next_tool();
279            return;
280        }
281        match self.selected_tool {
282            Some(AnalysisTool::Describe) => {
283                if let Some(current) = self.table_state.selected() {
284                    let next = (current + 1).min(max_rows.saturating_sub(1));
285                    self.table_state.select(Some(next));
286                } else {
287                    self.table_state.select(Some(0));
288                }
289            }
290            Some(AnalysisTool::DistributionAnalysis) => {
291                if let Some(current) = self.distribution_table_state.selected() {
292                    let next = (current + 1).min(max_rows.saturating_sub(1));
293                    self.distribution_table_state.select(Some(next));
294                    self.selected_distribution = Some(next);
295                } else {
296                    self.distribution_table_state.select(Some(0));
297                    self.selected_distribution = Some(0);
298                }
299            }
300            Some(AnalysisTool::CorrelationMatrix) => {
301                if let Some((row, col)) = self.selected_correlation {
302                    let next_row = (row + 1).min(max_rows.saturating_sub(1));
303                    self.selected_correlation = Some((next_row, col));
304                    self.correlation_table_state.select(Some(next_row));
305                }
306            }
307            None => {}
308        }
309    }
310
311    pub fn previous_row(&mut self) {
312        if self.focus == AnalysisFocus::Sidebar {
313            self.previous_tool();
314            return;
315        }
316        match self.selected_tool {
317            Some(AnalysisTool::Describe) => {
318                if let Some(current) = self.table_state.selected() {
319                    if current > 0 {
320                        self.table_state.select(Some(current - 1));
321                    }
322                }
323            }
324            Some(AnalysisTool::DistributionAnalysis) => {
325                if let Some(current) = self.distribution_table_state.selected() {
326                    if current > 0 {
327                        let prev = current - 1;
328                        self.distribution_table_state.select(Some(prev));
329                        self.selected_distribution = Some(prev);
330                    }
331                }
332            }
333            Some(AnalysisTool::CorrelationMatrix) => {
334                if let Some((row, col)) = self.selected_correlation {
335                    if row > 0 {
336                        let prev_row = row - 1;
337                        self.selected_correlation = Some((prev_row, col));
338                        self.correlation_table_state.select(Some(prev_row));
339                    }
340                }
341            }
342            None => {}
343        }
344    }
345
346    pub fn page_down(&mut self, max_rows: usize, page_size: usize) {
347        if self.focus == AnalysisFocus::Sidebar {
348            return;
349        }
350
351        match self.selected_tool {
352            Some(AnalysisTool::Describe) => {
353                if let Some(current) = self.table_state.selected() {
354                    let next = (current + page_size).min(max_rows.saturating_sub(1));
355                    self.table_state.select(Some(next));
356                }
357            }
358            Some(AnalysisTool::DistributionAnalysis) => {
359                if let Some(current) = self.distribution_table_state.selected() {
360                    let next = (current + page_size).min(max_rows.saturating_sub(1));
361                    self.distribution_table_state.select(Some(next));
362                    self.selected_distribution = Some(next);
363                }
364            }
365            Some(AnalysisTool::CorrelationMatrix) => {
366                if let Some((row, col)) = self.selected_correlation {
367                    let next_row = (row + page_size).min(max_rows.saturating_sub(1));
368                    self.selected_correlation = Some((next_row, col));
369                    self.correlation_table_state.select(Some(next_row));
370                }
371            }
372            None => {}
373        }
374    }
375
376    pub fn page_up(&mut self, page_size: usize) {
377        if self.focus == AnalysisFocus::Sidebar {
378            return;
379        }
380
381        match self.selected_tool {
382            Some(AnalysisTool::Describe) => {
383                if let Some(current) = self.table_state.selected() {
384                    let next = current.saturating_sub(page_size);
385                    self.table_state.select(Some(next));
386                }
387            }
388            Some(AnalysisTool::DistributionAnalysis) => {
389                if let Some(current) = self.distribution_table_state.selected() {
390                    let next = current.saturating_sub(page_size);
391                    self.distribution_table_state.select(Some(next));
392                    self.selected_distribution = Some(next);
393                }
394            }
395            Some(AnalysisTool::CorrelationMatrix) => {
396                if let Some((row, col)) = self.selected_correlation {
397                    let prev_row = row.saturating_sub(page_size);
398                    self.selected_correlation = Some((prev_row, col));
399                    self.correlation_table_state.select(Some(prev_row));
400                }
401            }
402            None => {}
403        }
404    }
405
406    pub fn move_correlation_cell(
407        &mut self,
408        direction: (i32, i32),
409        max_rows: usize,
410        max_cols: usize,
411        visible_cols: usize,
412    ) {
413        if let Some((row, col)) = self.selected_correlation {
414            let new_row = ((row as i32) + direction.0)
415                .max(0)
416                .min((max_rows - 1) as i32) as usize;
417            let new_col = ((col as i32) + direction.1)
418                .max(0)
419                .min((max_cols - 1) as i32) as usize;
420            self.selected_correlation = Some((new_row, new_col));
421            self.correlation_table_state.select(Some(new_row));
422
423            if new_col < self.correlation_column_offset {
424                self.correlation_column_offset = new_col;
425            } else if new_col >= self.correlation_column_offset + visible_cols.saturating_sub(1) {
426                if new_col >= visible_cols {
427                    self.correlation_column_offset =
428                        new_col.saturating_sub(visible_cols.saturating_sub(1));
429                } else {
430                    self.correlation_column_offset = 0;
431                }
432            }
433        }
434    }
435
436    pub fn next_distribution(&mut self) {
437        let max_idx = 13;
438
439        if let Some(current) = self.distribution_selector_state.selected() {
440            let next = (current + 1).min(max_idx);
441            self.distribution_selector_state.select(Some(next));
442            self.select_distribution();
443        } else {
444            self.distribution_selector_state.select(Some(0));
445            self.select_distribution();
446        }
447    }
448
449    pub fn previous_distribution(&mut self) {
450        if let Some(current) = self.distribution_selector_state.selected() {
451            if current > 0 {
452                self.distribution_selector_state.select(Some(current - 1));
453                self.select_distribution();
454            }
455        } else {
456            self.distribution_selector_state.select(Some(0));
457            self.select_distribution();
458        }
459    }
460
461    pub fn select_distribution(&mut self) {
462        if let Some(idx) = self.distribution_selector_state.selected() {
463            if let Some(results) = &self.distribution_results {
464                let dist_analysis_idx = self.distribution_table_state.selected().unwrap_or(0);
465                if let Some(dist_analysis) = results.distribution_analyses.get(dist_analysis_idx) {
466                    // Use the same distribution list and p-value lookup as the widget
467                    let distributions = [
468                        ("Normal", DistributionType::Normal),
469                        ("LogNormal", DistributionType::LogNormal),
470                        ("Uniform", DistributionType::Uniform),
471                        ("PowerLaw", DistributionType::PowerLaw),
472                        ("Exponential", DistributionType::Exponential),
473                        ("Beta", DistributionType::Beta),
474                        ("Gamma", DistributionType::Gamma),
475                        ("Chi-Squared", DistributionType::ChiSquared),
476                        ("Student's t", DistributionType::StudentsT),
477                        ("Poisson", DistributionType::Poisson),
478                        ("Bernoulli", DistributionType::Bernoulli),
479                        ("Binomial", DistributionType::Binomial),
480                        ("Geometric", DistributionType::Geometric),
481                        ("Weibull", DistributionType::Weibull),
482                    ];
483
484                    let mut distribution_scores: Vec<(DistributionType, f64)> = distributions
485                        .iter()
486                        .map(|(_, dist_type)| {
487                            let p_value = dist_analysis
488                                .all_distribution_pvalues
489                                .get(dist_type)
490                                .copied()
491                                .unwrap_or_else(|| {
492                                    if *dist_type == DistributionType::Geometric {
493                                        0.01
494                                    } else {
495                                        0.0
496                                    }
497                                });
498                            (*dist_type, p_value)
499                        })
500                        .collect();
501
502                    distribution_scores
503                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
504
505                    let valid_idx = idx.min(distribution_scores.len().saturating_sub(1));
506                    if let Some((dist_type, _)) = distribution_scores.get(valid_idx) {
507                        self.selected_theoretical_distribution = *dist_type;
508                        if idx != valid_idx {
509                            self.distribution_selector_state.select(Some(valid_idx));
510                        }
511                    }
512                }
513            }
514        }
515    }
516}