use eframe::egui::{self, Color32, RichText, Ui};
use std::collections::{HashMap, VecDeque};
use super::charts::{BarChart, DonutChart, Histogram, LiveTicker, MethodDistribution, Sparkline};
use super::heatmaps::{ActivityHeatmap, CorrelationHeatmap, RiskHeatmap};
use super::rankings::{AmountDistribution, PatternStatsPanel, TopAccountsPanel};
use super::theme::AccNetTheme;
use crate::models::{AccountType, AccountingNetwork, SolvingMethod};
pub struct AnalyticsDashboard {
benford_counts: [usize; 9],
fraud_counts: [usize; 8],
gaap_counts: [usize; 6],
account_type_counts: [usize; 5],
account_type_balances: [f64; 5],
method_counts: [usize; 5],
volume_history: VecDeque<f32>,
risk_history: VecDeque<f32>,
flow_rate_history: VecDeque<f32>,
max_history: usize,
total_flows: usize,
#[allow(dead_code)]
total_entries: usize,
last_update_frame: u64,
account_metadata: HashMap<u16, String>,
expanded_sections: DashboardSections,
}
#[derive(Default)]
pub struct DashboardSections {
pub show_rankings: bool,
pub show_heatmaps: bool,
pub show_patterns: bool,
pub show_amounts: bool,
}
impl AnalyticsDashboard {
pub fn new() -> Self {
Self {
benford_counts: [0; 9],
fraud_counts: [0; 8],
gaap_counts: [0; 6],
account_type_counts: [0; 5],
account_type_balances: [0.0; 5],
method_counts: [0; 5],
volume_history: VecDeque::with_capacity(100),
risk_history: VecDeque::with_capacity(100),
flow_rate_history: VecDeque::with_capacity(100),
max_history: 100,
total_flows: 0,
total_entries: 0,
last_update_frame: 0,
account_metadata: HashMap::new(),
expanded_sections: DashboardSections {
show_rankings: true,
show_heatmaps: true,
show_patterns: true,
show_amounts: true,
},
}
}
pub fn set_account_metadata(&mut self, metadata: HashMap<u16, String>) {
self.account_metadata = metadata;
}
pub fn update(&mut self, network: &AccountingNetwork, frame: u64) {
if frame == self.last_update_frame {
return;
}
self.last_update_frame = frame;
self.benford_counts = [0; 9];
self.fraud_counts = [0; 8];
self.gaap_counts = [0; 6];
self.account_type_counts = [0; 5];
self.account_type_balances = [0.0; 5];
self.method_counts = [0; 5];
for account in &network.accounts {
let idx = match account.account_type {
AccountType::Asset => 0,
AccountType::Liability => 1,
AccountType::Equity => 2,
AccountType::Revenue => 3,
AccountType::Expense => 4,
AccountType::Contra => 0, };
self.account_type_counts[idx] += 1;
self.account_type_balances[idx] += account.closing_balance.to_f64().abs();
}
for flow in &network.flows {
let amount = flow.amount.to_f64().abs();
if amount >= 1.0 {
let first_digit = get_first_digit(amount);
if (1..=9).contains(&first_digit) {
self.benford_counts[(first_digit - 1) as usize] += 1;
}
}
let method_idx = match flow.method_used {
SolvingMethod::MethodA => 0,
SolvingMethod::MethodB => 1,
SolvingMethod::MethodC => 2,
SolvingMethod::MethodD => 3,
SolvingMethod::MethodE => 4,
SolvingMethod::Pending => continue, };
self.method_counts[method_idx] += 1;
}
self.fraud_counts[0] = network.statistics.fraud_pattern_count / 3; self.fraud_counts[2] = self.calculate_benford_violations();
let new_flows = network.flows.len();
let flow_rate = (new_flows as i64 - self.total_flows as i64).max(0) as f32;
self.total_flows = new_flows;
self.flow_rate_history.push_back(flow_rate);
if self.flow_rate_history.len() > self.max_history {
self.flow_rate_history.pop_front();
}
let risk = self.calculate_risk_score(network);
self.risk_history.push_back(risk);
if self.risk_history.len() > self.max_history {
self.risk_history.pop_front();
}
let volume = network
.flows
.iter()
.map(|f| f.amount.to_f64().abs())
.sum::<f64>() as f32
/ 1000.0; self.volume_history.push_back(volume);
if self.volume_history.len() > self.max_history {
self.volume_history.pop_front();
}
}
fn calculate_benford_violations(&self) -> usize {
let total: usize = self.benford_counts.iter().sum();
if total < 50 {
return 0;
}
let expected = [
0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
];
let mut chi_sq = 0.0;
for (i, &count) in self.benford_counts.iter().enumerate() {
let observed = count as f64 / total as f64;
let exp = expected[i];
chi_sq += (observed - exp).powi(2) / exp;
}
if chi_sq > 15.507 {
1
} else {
0
}
}
fn calculate_risk_score(&self, network: &AccountingNetwork) -> f32 {
let n = network.accounts.len().max(1) as f32;
let flows = network.flows.len().max(1) as f32;
let suspense_ratio = (network.statistics.suspense_account_count as f32 / n).min(0.2) / 0.2;
let violation_ratio =
(network.statistics.gaap_violation_count as f32 / flows * 100.0).min(10.0) / 10.0;
let fraud_ratio =
(network.statistics.fraud_pattern_count as f32 / flows * 100.0).min(5.0) / 5.0;
let benford_risk = self.calculate_benford_violations() as f32;
let avg_confidence = network.statistics.avg_confidence as f32;
let confidence_risk = (1.0 - avg_confidence).clamp(0.0, 1.0);
let risk = 0.20 * suspense_ratio
+ 0.25 * violation_ratio
+ 0.30 * fraud_ratio
+ 0.10 * benford_risk
+ 0.15 * confidence_risk;
risk.clamp(0.0, 1.0)
}
pub fn show(&mut self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
egui::ScrollArea::vertical().show(ui, |ui| {
self.show_ticker(ui, network, theme);
ui.add_space(10.0);
self.show_benford(ui, theme);
ui.add_space(15.0);
self.show_account_distribution(ui, theme);
ui.add_space(15.0);
self.show_method_distribution(ui, theme);
ui.add_space(15.0);
self.show_fraud_breakdown(ui, network, theme);
ui.add_space(15.0);
self.show_flow_rate(ui, theme);
ui.add_space(10.0);
self.show_risk_trend(ui, theme);
ui.add_space(15.0);
ui.separator();
ui.add_space(5.0);
let patterns_header = if self.expanded_sections.show_patterns {
"â–¼ Pattern Detection"
} else {
"â–¶ Pattern Detection"
};
if ui
.selectable_label(
self.expanded_sections.show_patterns,
RichText::new(patterns_header)
.color(theme.text_primary)
.size(12.0),
)
.clicked()
{
self.expanded_sections.show_patterns = !self.expanded_sections.show_patterns;
}
if self.expanded_sections.show_patterns {
ui.add_space(5.0);
self.show_pattern_detection(ui, network, theme);
ui.add_space(10.0);
}
let rankings_header = if self.expanded_sections.show_rankings {
"â–¼ Top Accounts"
} else {
"â–¶ Top Accounts"
};
if ui
.selectable_label(
self.expanded_sections.show_rankings,
RichText::new(rankings_header)
.color(theme.text_primary)
.size(12.0),
)
.clicked()
{
self.expanded_sections.show_rankings = !self.expanded_sections.show_rankings;
}
if self.expanded_sections.show_rankings {
ui.add_space(5.0);
self.show_top_accounts(ui, network, theme);
ui.add_space(10.0);
}
let amounts_header = if self.expanded_sections.show_amounts {
"â–¼ Amount Distribution"
} else {
"â–¶ Amount Distribution"
};
if ui
.selectable_label(
self.expanded_sections.show_amounts,
RichText::new(amounts_header)
.color(theme.text_primary)
.size(12.0),
)
.clicked()
{
self.expanded_sections.show_amounts = !self.expanded_sections.show_amounts;
}
if self.expanded_sections.show_amounts {
ui.add_space(5.0);
self.show_amount_distribution(ui, network, theme);
ui.add_space(10.0);
}
let heatmaps_header = if self.expanded_sections.show_heatmaps {
"â–¼ Heatmaps"
} else {
"â–¶ Heatmaps"
};
if ui
.selectable_label(
self.expanded_sections.show_heatmaps,
RichText::new(heatmaps_header)
.color(theme.text_primary)
.size(12.0),
)
.clicked()
{
self.expanded_sections.show_heatmaps = !self.expanded_sections.show_heatmaps;
}
if self.expanded_sections.show_heatmaps {
ui.add_space(5.0);
self.show_heatmaps(ui, network, theme);
ui.add_space(10.0);
}
});
}
fn show_pattern_detection(
&self,
ui: &mut Ui,
network: &AccountingNetwork,
theme: &AccNetTheme,
) {
let stats = PatternStatsPanel::from_network(network);
stats.show(ui, theme);
}
fn show_top_accounts(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
let account_names: HashMap<u16, String> = network
.account_metadata
.iter()
.map(|(&idx, meta)| (idx, meta.name.clone()))
.collect();
ui.horizontal(|ui| {
ui.label(RichText::new("By:").small().color(theme.text_secondary));
});
let pagerank_panel = TopAccountsPanel::by_pagerank(network, &account_names);
pagerank_panel.show(ui, theme);
ui.add_space(10.0);
let risk_panel = TopAccountsPanel::by_risk(network, &account_names);
risk_panel.show(ui, theme);
}
fn show_amount_distribution(
&self,
ui: &mut Ui,
network: &AccountingNetwork,
theme: &AccNetTheme,
) {
let dist = AmountDistribution::from_network(network);
dist.show(ui, theme);
}
fn show_heatmaps(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
let account_names: HashMap<u16, String> = network
.account_metadata
.iter()
.map(|(&idx, meta)| (idx, meta.name.clone()))
.collect();
let activity = ActivityHeatmap::from_network_by_type(network);
activity.show(ui, theme);
ui.add_space(10.0);
if network.accounts.len() >= 5 {
let correlation = CorrelationHeatmap::from_network(network, 8, &account_names);
correlation.show(ui, theme);
ui.add_space(10.0);
let risk = RiskHeatmap::from_network(network, 6, &account_names);
risk.show(ui, theme);
}
}
fn show_ticker(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
let ticker = LiveTicker::new()
.add(
"Accounts",
network.accounts.len().to_string(),
theme.asset_color,
)
.add("Flows", network.flows.len().to_string(), theme.flow_normal)
.add(
"Alerts",
network.statistics.gaap_violation_count.to_string(),
theme.alert_high,
)
.add(
"Fraud",
network.statistics.fraud_pattern_count.to_string(),
theme.alert_critical,
);
ticker.show(ui, theme);
}
fn show_benford(&self, ui: &mut Ui, theme: &AccNetTheme) {
let histogram = Histogram::benford(self.benford_counts);
histogram.show(ui, theme);
let total: usize = self.benford_counts.iter().sum();
if total >= 50 {
let violations = self.calculate_benford_violations();
let (text, color) = if violations > 0 {
("Distribution anomaly detected", theme.alert_high)
} else {
("Distribution normal", theme.alert_low)
};
ui.horizontal(|ui| {
ui.add_space(5.0);
ui.label(RichText::new(text).small().color(color));
});
}
}
fn show_account_distribution(&self, ui: &mut Ui, theme: &AccNetTheme) {
let chart = DonutChart::new("Account Types")
.add(
"Asset",
self.account_type_counts[0] as f64,
theme.asset_color,
)
.add(
"Liability",
self.account_type_counts[1] as f64,
theme.liability_color,
)
.add(
"Equity",
self.account_type_counts[2] as f64,
theme.equity_color,
)
.add(
"Revenue",
self.account_type_counts[3] as f64,
theme.revenue_color,
)
.add(
"Expense",
self.account_type_counts[4] as f64,
theme.expense_color,
);
chart.show(ui, theme);
}
fn show_method_distribution(&self, ui: &mut Ui, theme: &AccNetTheme) {
let dist = MethodDistribution::new(self.method_counts);
dist.show(ui, theme);
}
fn show_fraud_breakdown(&self, ui: &mut Ui, network: &AccountingNetwork, theme: &AccNetTheme) {
let fraud_count = network.statistics.fraud_pattern_count;
let gaap_count = network.statistics.gaap_violation_count;
let suspense = network.statistics.suspense_account_count;
let chart = BarChart::new("Detected Issues")
.add("Suspense Accts", suspense as f64, theme.alert_medium)
.add("GAAP Violations", gaap_count as f64, theme.alert_high)
.add("Fraud Patterns", fraud_count as f64, theme.alert_critical)
.add(
"Benford Anomaly",
self.calculate_benford_violations() as f64,
Color32::from_rgb(200, 100, 200),
);
chart.show(ui, theme);
}
fn show_flow_rate(&self, ui: &mut Ui, theme: &AccNetTheme) {
let mut sparkline = Sparkline::new("Flow Rate (per tick)");
sparkline.color = theme.flow_normal;
for &val in &self.flow_rate_history {
sparkline.push(val);
}
sparkline.show(ui, theme);
}
fn show_risk_trend(&self, ui: &mut Ui, theme: &AccNetTheme) {
let mut sparkline = Sparkline::new("Risk Score Trend");
sparkline.color = theme.alert_high;
for &val in &self.risk_history {
sparkline.push(val * 100.0); }
sparkline.show(ui, theme);
}
pub fn reset(&mut self) {
*self = Self::new();
}
pub fn benford_counts(&self) -> [usize; 9] {
self.benford_counts
}
pub fn account_type_counts(&self) -> [usize; 5] {
self.account_type_counts
}
pub fn account_type_balances(&self) -> [f64; 5] {
self.account_type_balances
}
pub fn method_counts(&self) -> [usize; 5] {
self.method_counts
}
pub fn flow_rate_history(&self) -> &VecDeque<f32> {
&self.flow_rate_history
}
pub fn risk_history(&self) -> &VecDeque<f32> {
&self.risk_history
}
}
impl Default for AnalyticsDashboard {
fn default() -> Self {
Self::new()
}
}
fn get_first_digit(mut n: f64) -> u8 {
n = n.abs();
while n >= 10.0 {
n /= 10.0;
}
while n < 1.0 && n > 0.0 {
n *= 10.0;
}
n as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_first_digit() {
assert_eq!(get_first_digit(123.45), 1);
assert_eq!(get_first_digit(9876.0), 9);
assert_eq!(get_first_digit(0.0045), 4);
assert_eq!(get_first_digit(5.0), 5);
}
#[test]
fn test_dashboard_creation() {
let dashboard = AnalyticsDashboard::new();
assert_eq!(dashboard.total_flows, 0);
}
}