Skip to main content

ringkernel_accnet/gui/
panels.rs

1//! UI panels for controls, analytics, and alerts.
2
3use super::theme::AccNetTheme;
4use crate::analytics::AnalyticsSnapshot;
5use crate::fabric::{Alert, AlertSeverity, PipelineConfig};
6use crate::models::AccountingNetwork;
7use eframe::egui::{self, Color32, RichText, Ui};
8
9/// Control panel for simulation settings.
10pub struct ControlPanel {
11    /// Pipeline configuration.
12    pub config: PipelineConfig,
13    /// Whether simulation is running.
14    pub running: bool,
15    /// Selected company archetype index.
16    pub archetype_index: usize,
17    /// Previous archetype index (to detect changes).
18    prev_archetype_index: usize,
19    /// Simulation speed multiplier.
20    pub speed: f32,
21    /// Anomaly injection rate.
22    pub anomaly_rate: f32,
23    /// Flag to signal reset needed.
24    pub needs_reset: bool,
25}
26
27impl ControlPanel {
28    /// Create a new control panel.
29    pub fn new() -> Self {
30        Self {
31            config: PipelineConfig::default(),
32            running: false,
33            archetype_index: 0,
34            prev_archetype_index: 0,
35            speed: 1.0,
36            anomaly_rate: 0.05,
37            needs_reset: false,
38        }
39    }
40
41    /// Render the control panel.
42    pub fn show(&mut self, ui: &mut Ui, theme: &AccNetTheme) {
43        ui.vertical(|ui| {
44            ui.heading(RichText::new("Simulation Control").color(theme.text_primary));
45            ui.separator();
46
47            // Play/Pause
48            ui.horizontal(|ui| {
49                let button_text = if self.running { "Pause" } else { "Start" };
50                if ui.button(RichText::new(button_text).size(16.0)).clicked() {
51                    self.running = !self.running;
52                }
53
54                if ui.button("Reset").clicked() {
55                    self.running = false;
56                    self.needs_reset = true;
57                }
58            });
59
60            ui.add_space(10.0);
61
62            // Company archetype
63            ui.label(RichText::new("Company Type").color(theme.text_secondary));
64            egui::ComboBox::from_id_salt("archetype")
65                .selected_text(match self.archetype_index {
66                    0 => "Retail",
67                    1 => "SaaS",
68                    2 => "Manufacturing",
69                    3 => "Professional Services",
70                    4 => "Financial Services",
71                    _ => "Retail",
72                })
73                .show_ui(ui, |ui| {
74                    ui.selectable_value(&mut self.archetype_index, 0, "Retail");
75                    ui.selectable_value(&mut self.archetype_index, 1, "SaaS");
76                    ui.selectable_value(&mut self.archetype_index, 2, "Manufacturing");
77                    ui.selectable_value(&mut self.archetype_index, 3, "Professional Services");
78                    ui.selectable_value(&mut self.archetype_index, 4, "Financial Services");
79                });
80
81            // Detect archetype change and trigger reset
82            if self.archetype_index != self.prev_archetype_index {
83                self.prev_archetype_index = self.archetype_index;
84                self.needs_reset = true;
85            }
86
87            ui.add_space(10.0);
88
89            // Speed slider
90            ui.label(RichText::new("Speed").color(theme.text_secondary));
91            ui.add(egui::Slider::new(&mut self.speed, 0.1..=10.0).logarithmic(true));
92
93            ui.add_space(10.0);
94
95            // Anomaly rate
96            ui.label(RichText::new("Anomaly Injection Rate").color(theme.text_secondary));
97            ui.add(egui::Slider::new(&mut self.anomaly_rate, 0.0..=0.3).show_value(true));
98
99            ui.add_space(10.0);
100
101            // Batch size
102            ui.label(RichText::new("Entries per Batch").color(theme.text_secondary));
103            let mut batch = self.config.batch_size as f32;
104            ui.add(egui::Slider::new(&mut batch, 10.0..=1000.0).logarithmic(true));
105            self.config.batch_size = batch as usize;
106        });
107    }
108}
109
110impl Default for ControlPanel {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116/// Analytics panel showing real-time metrics.
117pub struct AnalyticsPanel {
118    /// Current snapshot.
119    snapshot: Option<AnalyticsSnapshot>,
120    /// Historical risk values for sparkline.
121    risk_history: Vec<f32>,
122    /// Max history points.
123    max_history: usize,
124}
125
126impl AnalyticsPanel {
127    /// Create a new analytics panel.
128    pub fn new() -> Self {
129        Self {
130            snapshot: None,
131            risk_history: Vec::new(),
132            max_history: 100,
133        }
134    }
135
136    /// Update with new snapshot.
137    pub fn update(&mut self, snapshot: AnalyticsSnapshot) {
138        self.risk_history.push(snapshot.overall_risk);
139        if self.risk_history.len() > self.max_history {
140            self.risk_history.remove(0);
141        }
142        self.snapshot = Some(snapshot);
143    }
144
145    /// Render the analytics panel.
146    pub fn show(&mut self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
147        ui.vertical(|ui| {
148            ui.heading(RichText::new("Analytics").color(theme.text_primary));
149            ui.separator();
150
151            // Overall risk gauge
152            if let Some(ref snapshot) = self.snapshot {
153                ui.label(RichText::new("Overall Risk").color(theme.text_secondary));
154                self.draw_risk_gauge(ui, snapshot.overall_risk, theme);
155
156                ui.add_space(10.0);
157
158                // Metrics grid
159                ui.horizontal(|ui| {
160                    self.metric_box(
161                        ui,
162                        "Accounts",
163                        &network.accounts.len().to_string(),
164                        theme.asset_color,
165                    );
166                    self.metric_box(
167                        ui,
168                        "Flows",
169                        &network.flows.len().to_string(),
170                        theme.flow_normal,
171                    );
172                });
173
174                ui.horizontal(|ui| {
175                    self.metric_box(
176                        ui,
177                        "Suspense",
178                        &snapshot.suspense_accounts.to_string(),
179                        theme.alert_medium,
180                    );
181                    self.metric_box(
182                        ui,
183                        "GAAP",
184                        &snapshot.gaap_violations.to_string(),
185                        theme.alert_high,
186                    );
187                });
188
189                ui.horizontal(|ui| {
190                    self.metric_box(
191                        ui,
192                        "Fraud",
193                        &snapshot.fraud_patterns.to_string(),
194                        theme.alert_critical,
195                    );
196                    let health = format!("{:.0}%", snapshot.network_health.coverage * 100.0);
197                    self.metric_box(ui, "Health", &health, theme.equity_color);
198                });
199
200                ui.add_space(10.0);
201
202                // Sparkline
203                ui.label(RichText::new("Risk Trend").color(theme.text_secondary));
204                self.draw_sparkline(ui, theme);
205            } else {
206                ui.label("No data yet...");
207            }
208        });
209    }
210
211    /// Draw a risk gauge.
212    fn draw_risk_gauge(&self, ui: &mut Ui, risk: f32, theme: &AccNetTheme) {
213        let (rect, _response) =
214            ui.allocate_exact_size(egui::vec2(ui.available_width(), 30.0), egui::Sense::hover());
215
216        let painter = ui.painter();
217
218        // Background
219        painter.rect_filled(rect, 4.0, Color32::from_rgb(40, 40, 50));
220
221        // Fill
222        let fill_width = rect.width() * risk;
223        let fill_rect = egui::Rect::from_min_size(rect.min, egui::vec2(fill_width, rect.height()));
224        let fill_color = self.risk_color(risk, theme);
225        painter.rect_filled(fill_rect, 4.0, fill_color);
226
227        // Text
228        let text = format!("{:.1}%", risk * 100.0);
229        painter.text(
230            rect.center(),
231            egui::Align2::CENTER_CENTER,
232            text,
233            egui::FontId::proportional(14.0),
234            Color32::WHITE,
235        );
236    }
237
238    /// Draw a metric box.
239    fn metric_box(&self, ui: &mut Ui, label: &str, value: &str, color: Color32) {
240        let (rect, _response) =
241            ui.allocate_exact_size(egui::vec2(90.0, 55.0), egui::Sense::hover());
242
243        let painter = ui.painter();
244
245        // Background
246        painter.rect_filled(
247            rect,
248            4.0,
249            Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 30),
250        );
251        painter.rect_stroke(
252            rect,
253            4.0,
254            egui::Stroke::new(
255                1.0,
256                Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 60),
257            ),
258        );
259
260        // Value (large, centered)
261        painter.text(
262            egui::Pos2::new(rect.center().x, rect.top() + 15.0),
263            egui::Align2::CENTER_TOP,
264            value,
265            egui::FontId::proportional(20.0),
266            color,
267        );
268
269        // Label (smaller, at bottom with padding)
270        painter.text(
271            egui::Pos2::new(rect.center().x, rect.bottom() - 6.0),
272            egui::Align2::CENTER_BOTTOM,
273            label,
274            egui::FontId::proportional(9.0),
275            Color32::from_rgb(160, 160, 175),
276        );
277    }
278
279    /// Draw sparkline of risk history.
280    fn draw_sparkline(&self, ui: &mut Ui, theme: &AccNetTheme) {
281        let (rect, _response) =
282            ui.allocate_exact_size(egui::vec2(ui.available_width(), 40.0), egui::Sense::hover());
283
284        if self.risk_history.is_empty() {
285            return;
286        }
287
288        let painter = ui.painter();
289
290        // Background
291        painter.rect_filled(rect, 2.0, Color32::from_rgb(30, 30, 40));
292
293        // Draw line
294        let n = self.risk_history.len();
295        let points: Vec<egui::Pos2> = self
296            .risk_history
297            .iter()
298            .enumerate()
299            .map(|(i, &risk)| {
300                let x = rect.left() + (i as f32 / n.max(1) as f32) * rect.width();
301                let y = rect.bottom() - risk * rect.height();
302                egui::Pos2::new(x, y)
303            })
304            .collect();
305
306        if points.len() >= 2 {
307            for i in 0..(points.len() - 1) {
308                painter.line_segment(
309                    [points[i], points[i + 1]],
310                    egui::Stroke::new(2.0, theme.accent),
311                );
312            }
313        }
314    }
315
316    /// Get color for risk level.
317    fn risk_color(&self, risk: f32, theme: &AccNetTheme) -> Color32 {
318        if risk > 0.7 {
319            theme.alert_critical
320        } else if risk > 0.5 {
321            theme.alert_high
322        } else if risk > 0.3 {
323            theme.alert_medium
324        } else {
325            theme.alert_low
326        }
327    }
328}
329
330impl Default for AnalyticsPanel {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336/// Alerts panel showing detected anomalies.
337pub struct AlertsPanel {
338    /// Alert history.
339    alerts: Vec<Alert>,
340    /// Maximum alerts to keep.
341    max_alerts: usize,
342    /// Filter by severity.
343    pub min_severity: AlertSeverity,
344}
345
346impl AlertsPanel {
347    /// Create a new alerts panel.
348    pub fn new() -> Self {
349        Self {
350            alerts: Vec::new(),
351            max_alerts: 100,
352            min_severity: AlertSeverity::Low,
353        }
354    }
355
356    /// Add an alert.
357    pub fn add_alert(&mut self, alert: Alert) {
358        self.alerts.insert(0, alert);
359        if self.alerts.len() > self.max_alerts {
360            self.alerts.pop();
361        }
362    }
363
364    /// Clear all alerts.
365    pub fn clear(&mut self) {
366        self.alerts.clear();
367    }
368
369    /// Render the alerts panel.
370    pub fn show(&mut self, ui: &mut Ui, theme: &AccNetTheme) {
371        ui.vertical(|ui| {
372            ui.horizontal(|ui| {
373                ui.heading(RichText::new("Alerts").color(theme.text_primary));
374                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
375                    if ui.small_button("Clear").clicked() {
376                        self.clear();
377                    }
378                });
379            });
380            ui.separator();
381
382            // Severity filter
383            ui.horizontal(|ui| {
384                ui.label("Min Severity:");
385                egui::ComboBox::from_id_salt("severity_filter")
386                    .selected_text(format!("{:?}", self.min_severity))
387                    .show_ui(ui, |ui| {
388                        ui.selectable_value(&mut self.min_severity, AlertSeverity::Low, "Low");
389                        ui.selectable_value(
390                            &mut self.min_severity,
391                            AlertSeverity::Medium,
392                            "Medium",
393                        );
394                        ui.selectable_value(&mut self.min_severity, AlertSeverity::High, "High");
395                        ui.selectable_value(
396                            &mut self.min_severity,
397                            AlertSeverity::Critical,
398                            "Critical",
399                        );
400                    });
401            });
402
403            ui.add_space(5.0);
404
405            // Scrollable alert list
406            egui::ScrollArea::vertical()
407                .max_height(300.0)
408                .show(ui, |ui| {
409                    for alert in &self.alerts {
410                        if self.should_show(alert) {
411                            self.render_alert(ui, alert, theme);
412                        }
413                    }
414
415                    if self.alerts.is_empty() {
416                        ui.label(RichText::new("No alerts").color(theme.text_secondary));
417                    }
418                });
419        });
420    }
421
422    /// Check if alert should be shown based on filter.
423    fn should_show(&self, alert: &Alert) -> bool {
424        alert.severity as u8 >= self.min_severity as u8
425    }
426
427    /// Render a single alert.
428    fn render_alert(&self, ui: &mut Ui, alert: &Alert, theme: &AccNetTheme) {
429        let color = match alert.severity {
430            AlertSeverity::Info => theme.text_secondary,
431            AlertSeverity::Low => theme.alert_low,
432            AlertSeverity::Medium => theme.alert_medium,
433            AlertSeverity::High => theme.alert_high,
434            AlertSeverity::Critical => theme.alert_critical,
435        };
436
437        egui::Frame::none()
438            .fill(Color32::from_rgba_unmultiplied(
439                color.r(),
440                color.g(),
441                color.b(),
442                20,
443            ))
444            .inner_margin(8.0)
445            .outer_margin(2.0)
446            .rounding(4.0)
447            .show(ui, |ui| {
448                ui.horizontal(|ui| {
449                    // Severity indicator
450                    let (rect, _) =
451                        ui.allocate_exact_size(egui::vec2(4.0, 40.0), egui::Sense::hover());
452                    ui.painter().rect_filled(rect, 2.0, color);
453
454                    ui.vertical(|ui| {
455                        // Alert type as title
456                        ui.label(RichText::new(&alert.alert_type).strong().color(color));
457
458                        // Message
459                        ui.label(
460                            RichText::new(&alert.message)
461                                .small()
462                                .color(theme.text_secondary),
463                        );
464
465                        // Timestamp
466                        let time = chrono::DateTime::from_timestamp(
467                            (alert.timestamp.physical / 1000) as i64,
468                            0,
469                        )
470                        .map(|dt| dt.format("%H:%M:%S").to_string())
471                        .unwrap_or_else(|| "Unknown".to_string());
472                        ui.label(RichText::new(time).small().color(theme.text_secondary));
473                    });
474                });
475            });
476    }
477}
478
479impl Default for AlertsPanel {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485/// Educational overlay for explaining concepts.
486pub struct EducationalOverlay {
487    /// Whether overlay is visible.
488    pub visible: bool,
489    /// Current topic.
490    pub topic: EducationalTopic,
491}
492
493/// Educational topics.
494#[derive(Debug, Clone, Copy, PartialEq)]
495pub enum EducationalTopic {
496    /// Double-entry bookkeeping.
497    DoubleEntry,
498    /// Fraud patterns.
499    FraudPatterns,
500    /// GAAP compliance.
501    GaapCompliance,
502    /// Benford's Law.
503    BenfordsLaw,
504    /// Network metrics.
505    NetworkMetrics,
506}
507
508impl EducationalOverlay {
509    /// Create a new overlay.
510    pub fn new() -> Self {
511        Self {
512            visible: false,
513            topic: EducationalTopic::DoubleEntry,
514        }
515    }
516
517    /// Render the overlay.
518    pub fn show(&mut self, ui: &mut Ui, theme: &AccNetTheme) {
519        if !self.visible {
520            return;
521        }
522
523        egui::Window::new("Learn")
524            .collapsible(true)
525            .resizable(true)
526            .default_width(400.0)
527            .show(ui.ctx(), |ui| {
528                self.show_content(ui, theme);
529            });
530    }
531
532    /// Render the overlay content (called from app.rs window).
533    pub fn show_content(&mut self, ui: &mut Ui, theme: &AccNetTheme) {
534        // Topic tabs
535        ui.horizontal(|ui| {
536            if ui
537                .selectable_label(self.topic == EducationalTopic::DoubleEntry, "Double Entry")
538                .clicked()
539            {
540                self.topic = EducationalTopic::DoubleEntry;
541            }
542            if ui
543                .selectable_label(self.topic == EducationalTopic::FraudPatterns, "Fraud")
544                .clicked()
545            {
546                self.topic = EducationalTopic::FraudPatterns;
547            }
548            if ui
549                .selectable_label(self.topic == EducationalTopic::GaapCompliance, "GAAP")
550                .clicked()
551            {
552                self.topic = EducationalTopic::GaapCompliance;
553            }
554            if ui
555                .selectable_label(self.topic == EducationalTopic::BenfordsLaw, "Benford")
556                .clicked()
557            {
558                self.topic = EducationalTopic::BenfordsLaw;
559            }
560        });
561
562        ui.separator();
563
564        match self.topic {
565            EducationalTopic::DoubleEntry => self.show_double_entry(ui, theme),
566            EducationalTopic::FraudPatterns => self.show_fraud_patterns(ui, theme),
567            EducationalTopic::GaapCompliance => self.show_gaap(ui, theme),
568            EducationalTopic::BenfordsLaw => self.show_benford(ui, theme),
569            EducationalTopic::NetworkMetrics => self.show_metrics(ui, theme),
570        }
571    }
572
573    fn show_double_entry(&self, ui: &mut Ui, theme: &AccNetTheme) {
574        ui.label(
575            RichText::new("Double-Entry Bookkeeping")
576                .heading()
577                .color(theme.text_primary),
578        );
579        ui.add_space(5.0);
580        ui.label("Every financial transaction is recorded in at least two accounts:");
581        ui.label("- One account is debited (left side)");
582        ui.label("- One account is credited (right side)");
583        ui.add_space(10.0);
584        ui.label(RichText::new("The Accounting Equation:").strong());
585        ui.label("Assets = Liabilities + Equity");
586        ui.add_space(10.0);
587        ui.label("In this visualization, each node is an account, and edges represent money flowing between accounts.");
588    }
589
590    fn show_fraud_patterns(&self, ui: &mut Ui, theme: &AccNetTheme) {
591        ui.label(
592            RichText::new("Fraud Pattern Detection")
593                .heading()
594                .color(theme.text_primary),
595        );
596        ui.add_space(5.0);
597        ui.label("Common fraud patterns we detect:");
598        ui.add_space(5.0);
599
600        let patterns = [
601            (
602                "Circular Flows",
603                "Money cycling through accounts back to origin",
604            ),
605            (
606                "Benford Violation",
607                "Digit distribution doesn't match natural patterns",
608            ),
609            (
610                "Threshold Clustering",
611                "Amounts clustered just below approval limits",
612            ),
613            ("Round Amounts", "Excessive use of round numbers"),
614            ("Timing Anomalies", "Unusual transaction timing patterns"),
615        ];
616
617        for (name, desc) in patterns {
618            ui.horizontal(|ui| {
619                ui.label(RichText::new(name).strong().color(theme.alert_high));
620                ui.label(RichText::new(format!(" - {}", desc)).color(theme.text_secondary));
621            });
622        }
623    }
624
625    fn show_gaap(&self, ui: &mut Ui, theme: &AccNetTheme) {
626        ui.label(
627            RichText::new("GAAP Compliance")
628                .heading()
629                .color(theme.text_primary),
630        );
631        ui.add_space(5.0);
632        ui.label("Generally Accepted Accounting Principles (GAAP) violations:");
633        ui.add_space(5.0);
634
635        let violations = [
636            ("Revenue → Cash", "Revenue should flow through A/R first"),
637            (
638                "Revenue → Expense",
639                "Revenue shouldn't directly offset expenses",
640            ),
641            ("Asset → Equity", "Direct transfers bypass income statement"),
642        ];
643
644        for (name, desc) in violations {
645            ui.horizontal(|ui| {
646                ui.label(RichText::new(name).strong().color(theme.alert_medium));
647                ui.label(RichText::new(format!(" - {}", desc)).color(theme.text_secondary));
648            });
649        }
650    }
651
652    fn show_benford(&self, ui: &mut Ui, theme: &AccNetTheme) {
653        ui.label(
654            RichText::new("Benford's Law")
655                .heading()
656                .color(theme.text_primary),
657        );
658        ui.add_space(5.0);
659        ui.label("In naturally occurring datasets, leading digits follow a specific distribution:");
660        ui.add_space(5.0);
661
662        let expected = [
663            ("1", "30.1%"),
664            ("2", "17.6%"),
665            ("3", "12.5%"),
666            ("4", "9.7%"),
667            ("5", "7.9%"),
668            ("6", "6.7%"),
669            ("7", "5.8%"),
670            ("8", "5.1%"),
671            ("9", "4.6%"),
672        ];
673
674        ui.horizontal_wrapped(|ui| {
675            for (digit, pct) in expected {
676                ui.label(RichText::new(format!("{}: {}", digit, pct)).monospace());
677            }
678        });
679
680        ui.add_space(10.0);
681        ui.label("Fraudulent data often violates this distribution because humans tend to fabricate 'random' numbers differently.");
682    }
683
684    fn show_metrics(&self, ui: &mut Ui, theme: &AccNetTheme) {
685        ui.label(
686            RichText::new("Network Metrics")
687                .heading()
688                .color(theme.text_primary),
689        );
690        ui.add_space(5.0);
691        ui.label("We analyze the accounting network using graph theory:");
692        ui.add_space(5.0);
693
694        let metrics = [
695            ("PageRank", "Identifies central/important accounts"),
696            ("Density", "How interconnected the network is"),
697            ("Clustering", "Groups of tightly connected accounts"),
698        ];
699
700        for (name, desc) in metrics {
701            ui.horizontal(|ui| {
702                ui.label(RichText::new(name).strong().color(theme.accent));
703                ui.label(RichText::new(format!(" - {}", desc)).color(theme.text_secondary));
704            });
705        }
706    }
707}
708
709impl Default for EducationalOverlay {
710    fn default() -> Self {
711        Self::new()
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn test_control_panel() {
721        let panel = ControlPanel::new();
722        assert!(!panel.running);
723    }
724
725    #[test]
726    fn test_analytics_panel() {
727        let panel = AnalyticsPanel::new();
728        assert!(panel.snapshot.is_none());
729    }
730
731    #[test]
732    fn test_alerts_panel() {
733        let panel = AlertsPanel::new();
734        assert!(panel.alerts.is_empty());
735    }
736}