1use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Vec2};
9use std::collections::HashMap;
10
11use super::theme::AccNetTheme;
12use crate::models::{AccountFlags, AccountType, AccountingNetwork};
13
14#[derive(Clone)]
16pub struct RankedAccount {
17 pub index: u16,
19 pub name: String,
21 pub account_type: AccountType,
23 pub value: f64,
25 pub risk: f32,
27}
28
29pub struct TopAccountsPanel {
31 pub title: String,
33 pub accounts: Vec<RankedAccount>,
35 pub max_display: usize,
37 pub use_type_colors: bool,
39}
40
41impl TopAccountsPanel {
42 pub fn new(title: impl Into<String>) -> Self {
44 Self {
45 title: title.into(),
46 accounts: Vec::new(),
47 max_display: 10,
48 use_type_colors: true,
49 }
50 }
51
52 pub fn by_pagerank(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
54 let pagerank = network.compute_pagerank(20, 0.85);
55
56 let mut accounts: Vec<RankedAccount> = pagerank
57 .iter()
58 .enumerate()
59 .filter_map(|(idx, &rank)| {
60 if idx < network.accounts.len() {
61 let acc = &network.accounts[idx];
62 let name = metadata
63 .get(&acc.index)
64 .cloned()
65 .unwrap_or_else(|| format!("#{}", acc.index));
66 let risk = Self::calculate_account_risk(acc);
67 Some(RankedAccount {
68 index: acc.index,
69 name,
70 account_type: acc.account_type,
71 value: rank,
72 risk,
73 })
74 } else {
75 None
76 }
77 })
78 .collect();
79
80 accounts.sort_by(|a, b| {
81 b.value
82 .partial_cmp(&a.value)
83 .unwrap_or(std::cmp::Ordering::Equal)
84 });
85 accounts.truncate(10);
86
87 Self {
88 title: "Top Accounts by PageRank".to_string(),
89 accounts,
90 max_display: 10,
91 use_type_colors: true,
92 }
93 }
94
95 pub fn by_centrality(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
97 let max_possible = (network.accounts.len().saturating_sub(1) * 2) as f64;
98
99 let mut accounts: Vec<RankedAccount> = network
100 .accounts
101 .iter()
102 .map(|acc| {
103 let degree = (acc.in_degree + acc.out_degree) as f64;
104 let centrality = if max_possible > 0.0 {
105 degree / max_possible
106 } else {
107 0.0
108 };
109 let name = metadata
110 .get(&acc.index)
111 .cloned()
112 .unwrap_or_else(|| format!("#{}", acc.index));
113 let risk = Self::calculate_account_risk(acc);
114 RankedAccount {
115 index: acc.index,
116 name,
117 account_type: acc.account_type,
118 value: centrality,
119 risk,
120 }
121 })
122 .collect();
123
124 accounts.sort_by(|a, b| {
125 b.value
126 .partial_cmp(&a.value)
127 .unwrap_or(std::cmp::Ordering::Equal)
128 });
129 accounts.truncate(10);
130
131 Self {
132 title: "Top Accounts by Centrality".to_string(),
133 accounts,
134 max_display: 10,
135 use_type_colors: true,
136 }
137 }
138
139 pub fn by_risk(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
141 let mut accounts: Vec<RankedAccount> = network
142 .accounts
143 .iter()
144 .map(|acc| {
145 let risk = Self::calculate_account_risk(acc);
146 let name = metadata
147 .get(&acc.index)
148 .cloned()
149 .unwrap_or_else(|| format!("#{}", acc.index));
150 RankedAccount {
151 index: acc.index,
152 name,
153 account_type: acc.account_type,
154 value: risk as f64,
155 risk,
156 }
157 })
158 .collect();
159
160 accounts.sort_by(|a, b| {
161 b.value
162 .partial_cmp(&a.value)
163 .unwrap_or(std::cmp::Ordering::Equal)
164 });
165 accounts.truncate(10);
166
167 Self {
168 title: "Top Accounts by Risk".to_string(),
169 accounts,
170 max_display: 10,
171 use_type_colors: false, }
173 }
174
175 pub fn by_volume(network: &AccountingNetwork, metadata: &HashMap<u16, String>) -> Self {
177 let max_volume = network
178 .accounts
179 .iter()
180 .map(|a| a.transaction_count as f64)
181 .fold(1.0, f64::max);
182
183 let mut accounts: Vec<RankedAccount> = network
184 .accounts
185 .iter()
186 .map(|acc| {
187 let volume = acc.transaction_count as f64 / max_volume;
188 let name = metadata
189 .get(&acc.index)
190 .cloned()
191 .unwrap_or_else(|| format!("#{}", acc.index));
192 let risk = Self::calculate_account_risk(acc);
193 RankedAccount {
194 index: acc.index,
195 name,
196 account_type: acc.account_type,
197 value: volume,
198 risk,
199 }
200 })
201 .collect();
202
203 accounts.sort_by(|a, b| {
204 b.value
205 .partial_cmp(&a.value)
206 .unwrap_or(std::cmp::Ordering::Equal)
207 });
208 accounts.truncate(10);
209
210 Self {
211 title: "Top Accounts by Volume".to_string(),
212 accounts,
213 max_display: 10,
214 use_type_colors: true,
215 }
216 }
217
218 fn calculate_account_risk(acc: &crate::models::AccountNode) -> f32 {
220 let mut risk = 0.0f32;
221
222 if acc.flags.has(AccountFlags::IS_SUSPENSE_ACCOUNT) {
224 risk += 0.4;
225 }
226
227 if acc.flags.has(AccountFlags::HAS_GAAP_VIOLATION) {
229 risk += 0.25;
230 }
231
232 if acc.flags.has(AccountFlags::HAS_FRAUD_PATTERN) {
234 risk += 0.3;
235 }
236
237 if acc.flags.has(AccountFlags::HAS_ANOMALY) {
239 risk += 0.2;
240 }
241
242 let degree = (acc.in_degree + acc.out_degree) as f32;
244 if degree > 50.0 {
245 risk += 0.1;
246 }
247
248 risk.min(1.0)
249 }
250
251 pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
253 let width = ui.available_width();
254 let row_height = 18.0;
255 let header_height = 20.0;
256 let total_height =
257 header_height + self.accounts.len().min(self.max_display) as f32 * row_height + 10.0;
258
259 let (response, painter) =
260 ui.allocate_painter(Vec2::new(width, total_height), Sense::hover());
261 let rect = response.rect;
262
263 painter.text(
265 Pos2::new(rect.left() + 5.0, rect.top()),
266 egui::Align2::LEFT_TOP,
267 &self.title,
268 egui::FontId::proportional(11.0),
269 theme.text_secondary,
270 );
271
272 if self.accounts.is_empty() {
273 painter.text(
274 Pos2::new(rect.center().x, rect.top() + 40.0),
275 egui::Align2::CENTER_CENTER,
276 "No data",
277 egui::FontId::proportional(10.0),
278 theme.text_secondary,
279 );
280 return response;
281 }
282
283 let max_value = self.accounts.iter().map(|a| a.value).fold(0.001, f64::max);
284
285 let rank_width = 20.0;
286 let name_width = 70.0;
287 let bar_area_start = rect.left() + rank_width + name_width;
288 let bar_area_width = width - rank_width - name_width - 50.0;
289
290 for (i, account) in self.accounts.iter().take(self.max_display).enumerate() {
291 let y = rect.top() + header_height + i as f32 * row_height;
292
293 painter.text(
295 Pos2::new(rect.left() + 5.0, y + row_height / 2.0),
296 egui::Align2::LEFT_CENTER,
297 format!("{}.", i + 1),
298 egui::FontId::proportional(9.0),
299 theme.text_secondary,
300 );
301
302 painter.text(
304 Pos2::new(rect.left() + rank_width, y + row_height / 2.0),
305 egui::Align2::LEFT_CENTER,
306 &account.name[..account.name.len().min(10)],
307 egui::FontId::proportional(9.0),
308 theme.text_primary,
309 );
310
311 let bar_width = ((account.value / max_value) as f32 * bar_area_width).max(3.0);
313 let bar_color = if self.use_type_colors {
314 Self::type_color(account.account_type, theme)
315 } else {
316 Self::risk_color(account.risk)
317 };
318
319 let bar_rect = Rect::from_min_size(
320 Pos2::new(bar_area_start, y + 2.0),
321 Vec2::new(bar_width, row_height - 4.0),
322 );
323 painter.rect_filled(bar_rect, 2.0, bar_color);
324
325 painter.text(
327 Pos2::new(bar_area_start + bar_area_width + 5.0, y + row_height / 2.0),
328 egui::Align2::LEFT_CENTER,
329 format!("{:.3}", account.value),
330 egui::FontId::proportional(8.0),
331 theme.text_secondary,
332 );
333
334 let risk_x = rect.right() - 10.0;
336 let risk_color = Self::risk_color(account.risk);
337 painter.circle_filled(Pos2::new(risk_x, y + row_height / 2.0), 4.0, risk_color);
338 }
339
340 response
341 }
342
343 fn type_color(account_type: AccountType, theme: &AccNetTheme) -> Color32 {
344 match account_type {
345 AccountType::Asset => theme.asset_color,
346 AccountType::Liability => theme.liability_color,
347 AccountType::Equity => theme.equity_color,
348 AccountType::Revenue => theme.revenue_color,
349 AccountType::Expense => theme.expense_color,
350 AccountType::Contra => Color32::from_rgb(150, 150, 150),
351 }
352 }
353
354 fn risk_color(risk: f32) -> Color32 {
355 if risk < 0.25 {
356 Color32::from_rgb(80, 180, 100) } else if risk < 0.5 {
358 Color32::from_rgb(180, 180, 80) } else if risk < 0.75 {
360 Color32::from_rgb(220, 140, 60) } else {
362 Color32::from_rgb(200, 60, 60) }
364 }
365}
366
367pub struct PatternStatsPanel {
369 pub circular_flows: usize,
371 pub velocity_anomalies: usize,
373 pub timing_anomalies: usize,
375 pub amount_clustering: usize,
377 pub dormant_reactivations: usize,
379 pub round_amounts: usize,
381}
382
383impl PatternStatsPanel {
384 pub fn from_network(network: &AccountingNetwork) -> Self {
386 let mut circular_flows = 0;
388 let mut velocity_anomalies = 0;
389 let mut amount_clustering = 0;
390 let mut dormant_reactivations = 0;
391 let mut round_amounts = 0;
392
393 for flow in &network.flows {
395 if flow.flags.has(crate::models::FlowFlags::IS_CIRCULAR) {
396 circular_flows += 1;
397 }
398 if flow.flags.has(crate::models::FlowFlags::IS_ANOMALOUS) {
399 velocity_anomalies += 1;
400 }
401
402 let amount = flow.amount.to_f64().abs();
404 if amount > 100.0 && amount % 1000.0 == 0.0 {
405 round_amounts += 1;
406 }
407
408 let threshold_distances = [
410 (amount - 9999.0).abs(),
411 (amount - 10000.0).abs(),
412 (amount - 4999.0).abs(),
413 (amount - 5000.0).abs(),
414 ];
415 if threshold_distances.iter().any(|&d| d < 100.0) {
416 amount_clustering += 1;
417 }
418 }
419
420 for acc in &network.accounts {
422 if acc.flags.has(AccountFlags::IS_DORMANT) && acc.transaction_count > 0 {
423 dormant_reactivations += 1;
424 }
425 }
426
427 let timing_anomalies = network.statistics.fraud_pattern_count / 4;
429
430 Self {
431 circular_flows,
432 velocity_anomalies,
433 timing_anomalies,
434 amount_clustering,
435 dormant_reactivations,
436 round_amounts,
437 }
438 }
439
440 pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
442 let width = ui.available_width();
443 let patterns = [
444 (
445 "Circular Flows",
446 self.circular_flows,
447 "⟲",
448 Color32::from_rgb(200, 80, 80),
449 ),
450 (
451 "Velocity Anomalies",
452 self.velocity_anomalies,
453 "⚡",
454 Color32::from_rgb(220, 160, 60),
455 ),
456 (
457 "Timing Anomalies",
458 self.timing_anomalies,
459 "⏰",
460 Color32::from_rgb(180, 100, 180),
461 ),
462 (
463 "Amount Clustering",
464 self.amount_clustering,
465 "▣",
466 Color32::from_rgb(100, 160, 200),
467 ),
468 (
469 "Dormant Reactivated",
470 self.dormant_reactivations,
471 "💤",
472 Color32::from_rgb(140, 140, 180),
473 ),
474 (
475 "Round Amounts",
476 self.round_amounts,
477 "○",
478 Color32::from_rgb(160, 200, 160),
479 ),
480 ];
481
482 let row_height = 18.0;
483 let total_height = 20.0 + patterns.len() as f32 * row_height + 5.0;
484
485 let (response, painter) =
486 ui.allocate_painter(Vec2::new(width, total_height), Sense::hover());
487 let rect = response.rect;
488
489 painter.text(
491 Pos2::new(rect.left() + 5.0, rect.top()),
492 egui::Align2::LEFT_TOP,
493 "Detected Patterns",
494 egui::FontId::proportional(11.0),
495 theme.text_secondary,
496 );
497
498 let _total: usize = patterns.iter().map(|(_, c, _, _)| c).sum();
499 let max_count = patterns
500 .iter()
501 .map(|(_, c, _, _)| *c)
502 .max()
503 .unwrap_or(1)
504 .max(1);
505
506 for (i, (name, count, icon, color)) in patterns.iter().enumerate() {
507 let y = rect.top() + 18.0 + i as f32 * row_height;
508
509 painter.text(
511 Pos2::new(rect.left() + 10.0, y + row_height / 2.0),
512 egui::Align2::LEFT_CENTER,
513 *icon,
514 egui::FontId::proportional(10.0),
515 *color,
516 );
517
518 painter.text(
520 Pos2::new(rect.left() + 25.0, y + row_height / 2.0),
521 egui::Align2::LEFT_CENTER,
522 *name,
523 egui::FontId::proportional(9.0),
524 theme.text_primary,
525 );
526
527 let bar_start = rect.left() + 120.0;
529 let bar_max_width = width - 160.0;
530 let bar_width = (*count as f32 / max_count as f32 * bar_max_width).max(2.0);
531
532 let bar_rect = Rect::from_min_size(
533 Pos2::new(bar_start, y + 4.0),
534 Vec2::new(bar_width, row_height - 8.0),
535 );
536 painter.rect_filled(bar_rect, 2.0, *color);
537
538 painter.text(
540 Pos2::new(rect.right() - 25.0, y + row_height / 2.0),
541 egui::Align2::RIGHT_CENTER,
542 count.to_string(),
543 egui::FontId::proportional(9.0),
544 if *count > 0 {
545 *color
546 } else {
547 theme.text_secondary
548 },
549 );
550 }
551
552 response
553 }
554}
555
556pub struct AmountDistribution {
558 pub buckets: Vec<usize>,
560 pub labels: Vec<String>,
562 pub title: String,
564}
565
566impl AmountDistribution {
567 pub fn from_network(network: &AccountingNetwork) -> Self {
569 let ranges = [
571 (0.0, 100.0, "<100"),
572 (100.0, 500.0, "100-500"),
573 (500.0, 1000.0, "500-1K"),
574 (1000.0, 5000.0, "1K-5K"),
575 (5000.0, 10000.0, "5K-10K"),
576 (10000.0, 50000.0, "10K-50K"),
577 (50000.0, 100000.0, "50K-100K"),
578 (100000.0, f64::MAX, ">100K"),
579 ];
580
581 let mut buckets = vec![0usize; ranges.len()];
582
583 for flow in &network.flows {
584 let amount = flow.amount.to_f64().abs();
585 for (i, &(min, max, _)) in ranges.iter().enumerate() {
586 if amount >= min && amount < max {
587 buckets[i] += 1;
588 break;
589 }
590 }
591 }
592
593 Self {
594 buckets,
595 labels: ranges.iter().map(|(_, _, l)| l.to_string()).collect(),
596 title: "Transaction Amount Distribution".to_string(),
597 }
598 }
599
600 pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
602 let width = ui.available_width();
603 let height = 90.0;
604
605 let (response, painter) = ui.allocate_painter(Vec2::new(width, height), Sense::hover());
606 let rect = response.rect;
607
608 painter.text(
610 Pos2::new(rect.left() + 5.0, rect.top()),
611 egui::Align2::LEFT_TOP,
612 &self.title,
613 egui::FontId::proportional(11.0),
614 theme.text_secondary,
615 );
616
617 if self.buckets.is_empty() {
618 return response;
619 }
620
621 let chart_left = rect.left() + 5.0;
622 let chart_top = rect.top() + 18.0;
623 let chart_width = width - 10.0;
624 let chart_height = height - 35.0;
625
626 let max_count = *self.buckets.iter().max().unwrap_or(&1).max(&1);
627 let bar_width = chart_width / self.buckets.len() as f32;
628 let gap = bar_width * 0.1;
629
630 painter.rect_filled(
632 Rect::from_min_size(
633 Pos2::new(chart_left, chart_top),
634 Vec2::new(chart_width, chart_height),
635 ),
636 2.0,
637 Color32::from_rgb(25, 25, 35),
638 );
639
640 for (i, &count) in self.buckets.iter().enumerate() {
642 let x = chart_left + i as f32 * bar_width;
643 let bar_height = (count as f32 / max_count as f32) * (chart_height - 5.0);
644
645 let bar_rect = Rect::from_min_max(
646 Pos2::new(x + gap, chart_top + chart_height - bar_height),
647 Pos2::new(x + bar_width - gap, chart_top + chart_height),
648 );
649
650 let t = i as f32 / (self.buckets.len() - 1) as f32;
652 let color = Color32::from_rgb(
653 (80.0 + 120.0 * t) as u8,
654 (180.0 - 60.0 * t) as u8,
655 (200.0 - 100.0 * t) as u8,
656 );
657
658 painter.rect_filled(bar_rect, 2.0, color);
659
660 if i < self.labels.len() {
662 painter.text(
663 Pos2::new(x + bar_width / 2.0, chart_top + chart_height + 3.0),
664 egui::Align2::CENTER_TOP,
665 &self.labels[i],
666 egui::FontId::proportional(7.0),
667 theme.text_secondary,
668 );
669 }
670 }
671
672 response
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use uuid::Uuid;
680
681 #[test]
682 fn test_risk_color() {
683 let low = TopAccountsPanel::risk_color(0.1);
684 let high = TopAccountsPanel::risk_color(0.9);
685 assert_ne!(low, high);
686 }
687
688 #[test]
689 fn test_pattern_stats() {
690 let network = AccountingNetwork::new(Uuid::new_v4(), 2024, 1);
691 let stats = PatternStatsPanel::from_network(&network);
692 assert_eq!(stats.circular_flows, 0);
693 }
694}