1use 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
9pub struct ControlPanel {
11 pub config: PipelineConfig,
13 pub running: bool,
15 pub archetype_index: usize,
17 prev_archetype_index: usize,
19 pub speed: f32,
21 pub anomaly_rate: f32,
23 pub needs_reset: bool,
25}
26
27impl ControlPanel {
28 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 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 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 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 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 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 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 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
116pub struct AnalyticsPanel {
118 snapshot: Option<AnalyticsSnapshot>,
120 risk_history: Vec<f32>,
122 max_history: usize,
124}
125
126impl AnalyticsPanel {
127 pub fn new() -> Self {
129 Self {
130 snapshot: None,
131 risk_history: Vec::new(),
132 max_history: 100,
133 }
134 }
135
136 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 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 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 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 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 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 painter.rect_filled(rect, 4.0, Color32::from_rgb(40, 40, 50));
220
221 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 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 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 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 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 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 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 painter.rect_filled(rect, 2.0, Color32::from_rgb(30, 30, 40));
292
293 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 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
336pub struct AlertsPanel {
338 alerts: Vec<Alert>,
340 max_alerts: usize,
342 pub min_severity: AlertSeverity,
344}
345
346impl AlertsPanel {
347 pub fn new() -> Self {
349 Self {
350 alerts: Vec::new(),
351 max_alerts: 100,
352 min_severity: AlertSeverity::Low,
353 }
354 }
355
356 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 pub fn clear(&mut self) {
366 self.alerts.clear();
367 }
368
369 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 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 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 fn should_show(&self, alert: &Alert) -> bool {
424 alert.severity as u8 >= self.min_severity as u8
425 }
426
427 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 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 ui.label(RichText::new(&alert.alert_type).strong().color(color));
457
458 ui.label(
460 RichText::new(&alert.message)
461 .small()
462 .color(theme.text_secondary),
463 );
464
465 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
485pub struct EducationalOverlay {
487 pub visible: bool,
489 pub topic: EducationalTopic,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq)]
495pub enum EducationalTopic {
496 DoubleEntry,
498 FraudPatterns,
500 GaapCompliance,
502 BenfordsLaw,
504 NetworkMetrics,
506}
507
508impl EducationalOverlay {
509 pub fn new() -> Self {
511 Self {
512 visible: false,
513 topic: EducationalTopic::DoubleEntry,
514 }
515 }
516
517 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 pub fn show_content(&mut self, ui: &mut Ui, theme: &AccNetTheme) {
534 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}