1use eframe::egui::{self, Color32, RichText, Ui};
7use std::collections::{HashMap, VecDeque};
8
9use super::charts::{BarChart, DonutChart, Histogram, LiveTicker, MethodDistribution, Sparkline};
10use super::heatmaps::{ActivityHeatmap, CorrelationHeatmap, RiskHeatmap};
11use super::rankings::{AmountDistribution, PatternStatsPanel, TopAccountsPanel};
12use super::theme::AccNetTheme;
13use crate::models::{AccountType, AccountingNetwork, SolvingMethod};
14
15pub struct AnalyticsDashboard {
17 benford_counts: [usize; 9],
19 fraud_counts: [usize; 8],
21 gaap_counts: [usize; 6],
23 account_type_counts: [usize; 5],
25 account_type_balances: [f64; 5],
27 method_counts: [usize; 5],
29 volume_history: VecDeque<f32>,
31 risk_history: VecDeque<f32>,
33 flow_rate_history: VecDeque<f32>,
35 max_history: usize,
37 total_flows: usize,
39 #[allow(dead_code)]
41 total_entries: usize,
42 last_update_frame: u64,
44 account_metadata: HashMap<u16, String>,
46 expanded_sections: DashboardSections,
48}
49
50#[derive(Default)]
52pub struct DashboardSections {
53 pub show_rankings: bool,
55 pub show_heatmaps: bool,
57 pub show_patterns: bool,
59 pub show_amounts: bool,
61}
62
63impl AnalyticsDashboard {
64 pub fn new() -> Self {
66 Self {
67 benford_counts: [0; 9],
68 fraud_counts: [0; 8],
69 gaap_counts: [0; 6],
70 account_type_counts: [0; 5],
71 account_type_balances: [0.0; 5],
72 method_counts: [0; 5],
73 volume_history: VecDeque::with_capacity(100),
74 risk_history: VecDeque::with_capacity(100),
75 flow_rate_history: VecDeque::with_capacity(100),
76 max_history: 100,
77 total_flows: 0,
78 total_entries: 0,
79 last_update_frame: 0,
80 account_metadata: HashMap::new(),
81 expanded_sections: DashboardSections {
82 show_rankings: true,
83 show_heatmaps: true,
84 show_patterns: true,
85 show_amounts: true,
86 },
87 }
88 }
89
90 pub fn set_account_metadata(&mut self, metadata: HashMap<u16, String>) {
92 self.account_metadata = metadata;
93 }
94
95 pub fn update(&mut self, network: &AccountingNetwork, frame: u64) {
97 if frame == self.last_update_frame {
99 return;
100 }
101 self.last_update_frame = frame;
102
103 self.benford_counts = [0; 9];
105 self.fraud_counts = [0; 8];
106 self.gaap_counts = [0; 6];
107 self.account_type_counts = [0; 5];
108 self.account_type_balances = [0.0; 5];
109 self.method_counts = [0; 5];
110
111 for account in &network.accounts {
113 let idx = match account.account_type {
114 AccountType::Asset => 0,
115 AccountType::Liability => 1,
116 AccountType::Equity => 2,
117 AccountType::Revenue => 3,
118 AccountType::Expense => 4,
119 AccountType::Contra => 0, };
121 self.account_type_counts[idx] += 1;
122 self.account_type_balances[idx] += account.closing_balance.to_f64().abs();
123 }
124
125 for flow in &network.flows {
127 let amount = flow.amount.to_f64().abs();
128 if amount >= 1.0 {
129 let first_digit = get_first_digit(amount);
131 if (1..=9).contains(&first_digit) {
132 self.benford_counts[(first_digit - 1) as usize] += 1;
133 }
134 }
135
136 let method_idx = match flow.method_used {
138 SolvingMethod::MethodA => 0,
139 SolvingMethod::MethodB => 1,
140 SolvingMethod::MethodC => 2,
141 SolvingMethod::MethodD => 3,
142 SolvingMethod::MethodE => 4,
143 SolvingMethod::Pending => continue, };
145 self.method_counts[method_idx] += 1;
146 }
147
148 self.fraud_counts[0] = network.statistics.fraud_pattern_count / 3; self.fraud_counts[2] = self.calculate_benford_violations();
152
153 let new_flows = network.flows.len();
155 let flow_rate = (new_flows as i64 - self.total_flows as i64).max(0) as f32;
156 self.total_flows = new_flows;
157
158 self.flow_rate_history.push_back(flow_rate);
160 if self.flow_rate_history.len() > self.max_history {
161 self.flow_rate_history.pop_front();
162 }
163
164 let risk = self.calculate_risk_score(network);
166 self.risk_history.push_back(risk);
167 if self.risk_history.len() > self.max_history {
168 self.risk_history.pop_front();
169 }
170
171 let volume = network
173 .flows
174 .iter()
175 .map(|f| f.amount.to_f64().abs())
176 .sum::<f64>() as f32
177 / 1000.0; self.volume_history.push_back(volume);
179 if self.volume_history.len() > self.max_history {
180 self.volume_history.pop_front();
181 }
182 }
183
184 fn calculate_benford_violations(&self) -> usize {
186 let total: usize = self.benford_counts.iter().sum();
187 if total < 50 {
188 return 0;
189 }
190
191 let expected = [
193 0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
194 ];
195 let mut chi_sq = 0.0;
196
197 for (i, &count) in self.benford_counts.iter().enumerate() {
198 let observed = count as f64 / total as f64;
199 let exp = expected[i];
200 chi_sq += (observed - exp).powi(2) / exp;
201 }
202
203 if chi_sq > 15.507 {
205 1
206 } else {
207 0
208 }
209 }
210
211 fn calculate_risk_score(&self, network: &AccountingNetwork) -> f32 {
214 let n = network.accounts.len().max(1) as f32;
215 let flows = network.flows.len().max(1) as f32;
216
217 let suspense_ratio = (network.statistics.suspense_account_count as f32 / n).min(0.2) / 0.2;
220
221 let violation_ratio =
224 (network.statistics.gaap_violation_count as f32 / flows * 100.0).min(10.0) / 10.0;
225
226 let fraud_ratio =
229 (network.statistics.fraud_pattern_count as f32 / flows * 100.0).min(5.0) / 5.0;
230
231 let benford_risk = self.calculate_benford_violations() as f32;
233
234 let avg_confidence = network.statistics.avg_confidence as f32;
236 let confidence_risk = (1.0 - avg_confidence).clamp(0.0, 1.0);
237
238 let risk = 0.20 * suspense_ratio
241 + 0.25 * violation_ratio
242 + 0.30 * fraud_ratio
243 + 0.10 * benford_risk
244 + 0.15 * confidence_risk;
245
246 risk.clamp(0.0, 1.0)
247 }
248
249 pub fn show(&mut self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
251 egui::ScrollArea::vertical().show(ui, |ui| {
252 self.show_ticker(ui, network, theme);
254 ui.add_space(10.0);
255
256 self.show_benford(ui, theme);
258 ui.add_space(15.0);
259
260 self.show_account_distribution(ui, theme);
262 ui.add_space(15.0);
263
264 self.show_method_distribution(ui, theme);
266 ui.add_space(15.0);
267
268 self.show_fraud_breakdown(ui, network, theme);
270 ui.add_space(15.0);
271
272 self.show_flow_rate(ui, theme);
274 ui.add_space(10.0);
275
276 self.show_risk_trend(ui, theme);
278 ui.add_space(15.0);
279
280 ui.separator();
284 ui.add_space(5.0);
285
286 let patterns_header = if self.expanded_sections.show_patterns {
288 "▼ Pattern Detection"
289 } else {
290 "▶ Pattern Detection"
291 };
292 if ui
293 .selectable_label(
294 self.expanded_sections.show_patterns,
295 RichText::new(patterns_header)
296 .color(theme.text_primary)
297 .size(12.0),
298 )
299 .clicked()
300 {
301 self.expanded_sections.show_patterns = !self.expanded_sections.show_patterns;
302 }
303 if self.expanded_sections.show_patterns {
304 ui.add_space(5.0);
305 self.show_pattern_detection(ui, network, theme);
306 ui.add_space(10.0);
307 }
308
309 let rankings_header = if self.expanded_sections.show_rankings {
311 "▼ Top Accounts"
312 } else {
313 "▶ Top Accounts"
314 };
315 if ui
316 .selectable_label(
317 self.expanded_sections.show_rankings,
318 RichText::new(rankings_header)
319 .color(theme.text_primary)
320 .size(12.0),
321 )
322 .clicked()
323 {
324 self.expanded_sections.show_rankings = !self.expanded_sections.show_rankings;
325 }
326 if self.expanded_sections.show_rankings {
327 ui.add_space(5.0);
328 self.show_top_accounts(ui, network, theme);
329 ui.add_space(10.0);
330 }
331
332 let amounts_header = if self.expanded_sections.show_amounts {
334 "▼ Amount Distribution"
335 } else {
336 "▶ Amount Distribution"
337 };
338 if ui
339 .selectable_label(
340 self.expanded_sections.show_amounts,
341 RichText::new(amounts_header)
342 .color(theme.text_primary)
343 .size(12.0),
344 )
345 .clicked()
346 {
347 self.expanded_sections.show_amounts = !self.expanded_sections.show_amounts;
348 }
349 if self.expanded_sections.show_amounts {
350 ui.add_space(5.0);
351 self.show_amount_distribution(ui, network, theme);
352 ui.add_space(10.0);
353 }
354
355 let heatmaps_header = if self.expanded_sections.show_heatmaps {
357 "▼ Heatmaps"
358 } else {
359 "▶ Heatmaps"
360 };
361 if ui
362 .selectable_label(
363 self.expanded_sections.show_heatmaps,
364 RichText::new(heatmaps_header)
365 .color(theme.text_primary)
366 .size(12.0),
367 )
368 .clicked()
369 {
370 self.expanded_sections.show_heatmaps = !self.expanded_sections.show_heatmaps;
371 }
372 if self.expanded_sections.show_heatmaps {
373 ui.add_space(5.0);
374 self.show_heatmaps(ui, network, theme);
375 ui.add_space(10.0);
376 }
377 });
378 }
379
380 fn show_pattern_detection(
382 &self,
383 ui: &mut Ui,
384 network: &AccountingNetwork,
385 theme: &AccNetTheme,
386 ) {
387 let stats = PatternStatsPanel::from_network(network);
388 stats.show(ui, theme);
389 }
390
391 fn show_top_accounts(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
393 let account_names: HashMap<u16, String> = network
395 .account_metadata
396 .iter()
397 .map(|(&idx, meta)| (idx, meta.name.clone()))
398 .collect();
399
400 ui.horizontal(|ui| {
402 ui.label(RichText::new("By:").small().color(theme.text_secondary));
403 });
404
405 let pagerank_panel = TopAccountsPanel::by_pagerank(network, &account_names);
407 pagerank_panel.show(ui, theme);
408
409 ui.add_space(10.0);
410
411 let risk_panel = TopAccountsPanel::by_risk(network, &account_names);
413 risk_panel.show(ui, theme);
414 }
415
416 fn show_amount_distribution(
418 &self,
419 ui: &mut Ui,
420 network: &AccountingNetwork,
421 theme: &AccNetTheme,
422 ) {
423 let dist = AmountDistribution::from_network(network);
424 dist.show(ui, theme);
425 }
426
427 fn show_heatmaps(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
429 let account_names: HashMap<u16, String> = network
431 .account_metadata
432 .iter()
433 .map(|(&idx, meta)| (idx, meta.name.clone()))
434 .collect();
435
436 let activity = ActivityHeatmap::from_network_by_type(network);
438 activity.show(ui, theme);
439
440 ui.add_space(10.0);
441
442 if network.accounts.len() >= 5 {
444 let correlation = CorrelationHeatmap::from_network(network, 8, &account_names);
445 correlation.show(ui, theme);
446
447 ui.add_space(10.0);
448
449 let risk = RiskHeatmap::from_network(network, 6, &account_names);
451 risk.show(ui, theme);
452 }
453 }
454
455 fn show_ticker(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
457 let ticker = LiveTicker::new()
458 .add(
459 "Accounts",
460 network.accounts.len().to_string(),
461 theme.asset_color,
462 )
463 .add("Flows", network.flows.len().to_string(), theme.flow_normal)
464 .add(
465 "Alerts",
466 network.statistics.gaap_violation_count.to_string(),
467 theme.alert_high,
468 )
469 .add(
470 "Fraud",
471 network.statistics.fraud_pattern_count.to_string(),
472 theme.alert_critical,
473 );
474
475 ticker.show(ui, theme);
476 }
477
478 fn show_benford(&self, ui: &mut Ui, theme: &AccNetTheme) {
480 let histogram = Histogram::benford(self.benford_counts);
481 histogram.show(ui, theme);
482
483 let total: usize = self.benford_counts.iter().sum();
485 if total >= 50 {
486 let violations = self.calculate_benford_violations();
487 let (text, color) = if violations > 0 {
488 ("Distribution anomaly detected", theme.alert_high)
489 } else {
490 ("Distribution normal", theme.alert_low)
491 };
492 ui.horizontal(|ui| {
493 ui.add_space(5.0);
494 ui.label(RichText::new(text).small().color(color));
495 });
496 }
497 }
498
499 fn show_account_distribution(&self, ui: &mut Ui, theme: &AccNetTheme) {
501 let chart = DonutChart::new("Account Types")
502 .add(
503 "Asset",
504 self.account_type_counts[0] as f64,
505 theme.asset_color,
506 )
507 .add(
508 "Liability",
509 self.account_type_counts[1] as f64,
510 theme.liability_color,
511 )
512 .add(
513 "Equity",
514 self.account_type_counts[2] as f64,
515 theme.equity_color,
516 )
517 .add(
518 "Revenue",
519 self.account_type_counts[3] as f64,
520 theme.revenue_color,
521 )
522 .add(
523 "Expense",
524 self.account_type_counts[4] as f64,
525 theme.expense_color,
526 );
527
528 chart.show(ui, theme);
529 }
530
531 fn show_method_distribution(&self, ui: &mut Ui, theme: &AccNetTheme) {
533 let dist = MethodDistribution::new(self.method_counts);
534 dist.show(ui, theme);
535 }
536
537 fn show_fraud_breakdown(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
539 let fraud_count = network.statistics.fraud_pattern_count;
540 let gaap_count = network.statistics.gaap_violation_count;
541 let suspense = network.statistics.suspense_account_count;
542
543 let chart = BarChart::new("Detected Issues")
544 .add("Suspense Accts", suspense as f64, theme.alert_medium)
545 .add("GAAP Violations", gaap_count as f64, theme.alert_high)
546 .add("Fraud Patterns", fraud_count as f64, theme.alert_critical)
547 .add(
548 "Benford Anomaly",
549 self.calculate_benford_violations() as f64,
550 Color32::from_rgb(200, 100, 200),
551 );
552
553 chart.show(ui, theme);
554 }
555
556 fn show_flow_rate(&self, ui: &mut Ui, theme: &AccNetTheme) {
558 let mut sparkline = Sparkline::new("Flow Rate (per tick)");
559 sparkline.color = theme.flow_normal;
560 for &val in &self.flow_rate_history {
561 sparkline.push(val);
562 }
563 sparkline.show(ui, theme);
564 }
565
566 fn show_risk_trend(&self, ui: &mut Ui, theme: &AccNetTheme) {
568 let mut sparkline = Sparkline::new("Risk Score Trend");
569 sparkline.color = theme.alert_high;
570 for &val in &self.risk_history {
571 sparkline.push(val * 100.0); }
573 sparkline.show(ui, theme);
574 }
575
576 pub fn reset(&mut self) {
578 *self = Self::new();
579 }
580
581 pub fn benford_counts(&self) -> [usize; 9] {
583 self.benford_counts
584 }
585
586 pub fn account_type_counts(&self) -> [usize; 5] {
588 self.account_type_counts
589 }
590
591 pub fn account_type_balances(&self) -> [f64; 5] {
593 self.account_type_balances
594 }
595
596 pub fn method_counts(&self) -> [usize; 5] {
598 self.method_counts
599 }
600
601 pub fn flow_rate_history(&self) -> &VecDeque<f32> {
603 &self.flow_rate_history
604 }
605
606 pub fn risk_history(&self) -> &VecDeque<f32> {
608 &self.risk_history
609 }
610}
611
612impl Default for AnalyticsDashboard {
613 fn default() -> Self {
614 Self::new()
615 }
616}
617
618fn get_first_digit(mut n: f64) -> u8 {
620 n = n.abs();
621 while n >= 10.0 {
622 n /= 10.0;
623 }
624 while n < 1.0 && n > 0.0 {
625 n *= 10.0;
626 }
627 n as u8
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn test_first_digit() {
636 assert_eq!(get_first_digit(123.45), 1);
637 assert_eq!(get_first_digit(9876.0), 9);
638 assert_eq!(get_first_digit(0.0045), 4);
639 assert_eq!(get_first_digit(5.0), 5);
640 }
641
642 #[test]
643 fn test_dashboard_creation() {
644 let dashboard = AnalyticsDashboard::new();
645 assert_eq!(dashboard.total_flows, 0);
646 }
647}