1use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Vec2};
7use nalgebra::Vector2;
8use uuid::Uuid;
9
10use super::animation::ParticleSystem;
11use super::layout::{ForceDirectedLayout, LayoutConfig};
12use super::theme::AccNetTheme;
13use crate::models::AccountingNetwork;
14
15pub struct NetworkCanvas {
17 pub layout: ForceDirectedLayout,
19 pub particles: ParticleSystem,
21 pub theme: AccNetTheme,
23 pub zoom: f32,
25 pub pan: Vec2,
27 pub selected_node: Option<u16>,
29 pub hovered_node: Option<u16>,
31 dragging_node: Option<u16>,
33 pub show_labels: bool,
35 pub show_risk: bool,
37 pub animate_layout: bool,
39 last_frame: std::time::Instant,
41}
42
43impl NetworkCanvas {
44 pub fn new() -> Self {
46 Self {
47 layout: ForceDirectedLayout::new(LayoutConfig::default()),
48 particles: ParticleSystem::new(2000),
49 theme: AccNetTheme::dark(),
50 zoom: 1.0,
51 pan: Vec2::ZERO,
52 selected_node: None,
53 hovered_node: None,
54 dragging_node: None,
55 show_labels: true,
56 show_risk: true,
57 animate_layout: true,
58 last_frame: std::time::Instant::now(),
59 }
60 }
61
62 pub fn initialize(&mut self, network: &AccountingNetwork) {
64 self.layout.initialize(network);
65 self.refresh_particles(network);
66 }
67
68 pub fn refresh_particles(&mut self, network: &AccountingNetwork) {
71 self.particles.clear();
72
73 let flow_count = network.flows.len();
75 let start_idx = flow_count.saturating_sub(500);
76
77 for flow in &network.flows[start_idx..] {
78 let suspicious = flow.is_anomalous();
79 let color = if suspicious {
80 self.theme.flow_suspicious
81 } else {
82 self.theme.flow_normal
83 };
84 self.particles.queue_flow(
85 flow.source_account_index,
86 flow.target_account_index,
87 Uuid::new_v4(),
88 color,
89 suspicious,
90 );
91 }
92 }
93
94 pub fn update(&mut self) {
96 let now = std::time::Instant::now();
97 let dt = (now - self.last_frame).as_secs_f32();
98 self.last_frame = now;
99
100 if self.animate_layout && !self.layout.converged {
102 self.layout.iterate(5);
103 }
104
105 self.particles.update(dt);
107 }
108
109 pub fn show(&mut self, ui: &mut egui::Ui, network: &AccountingNetwork) -> Response {
111 let (response, painter) = ui.allocate_painter(ui.available_size(), Sense::click_and_drag());
112
113 let rect = response.rect;
114
115 let current_width = self.layout.config.width;
117 let current_height = self.layout.config.height;
118 if (rect.width() - current_width).abs() > 1.0
119 || (rect.height() - current_height).abs() > 1.0
120 {
121 self.layout.resize(rect.width(), rect.height());
122 }
123
124 self.handle_input(ui, &response, rect);
126
127 painter.rect_filled(rect, 0.0, self.theme.canvas_bg);
129
130 self.draw_grid(&painter, rect);
132
133 let transform = |pos: Vector2<f32>| -> Pos2 {
135 let x = rect.left() + (pos.x + self.pan.x) * self.zoom;
136 let y = rect.top() + (pos.y + self.pan.y) * self.zoom;
137 Pos2::new(x, y)
138 };
139
140 let max_weight = self
142 .layout
143 .edges()
144 .iter()
145 .map(|(_, _, w)| *w)
146 .fold(1.0f32, f32::max);
147
148 let center = {
150 let (sum_x, sum_y, count) = self
151 .layout
152 .nodes()
153 .fold((0.0f32, 0.0f32, 0usize), |(sx, sy, c), node| {
154 (sx + node.position.x, sy + node.position.y, c + 1)
155 });
156 if count > 0 {
157 Vector2::new(sum_x / count as f32, sum_y / count as f32)
158 } else {
159 Vector2::new(
160 self.layout.config.width / 2.0,
161 self.layout.config.height / 2.0,
162 )
163 }
164 };
165
166 for (source_idx, target_idx, weight) in self.layout.edges() {
168 if let (Some(src_pos), Some(tgt_pos)) = (
169 self.layout.get_position(*source_idx),
170 self.layout.get_position(*target_idx),
171 ) {
172 let suspicious = network.flows.iter().any(|f| {
174 f.source_account_index == *source_idx
175 && f.target_account_index == *target_idx
176 && f.is_anomalous()
177 });
178
179 let color = self
181 .theme
182 .edge_color_futuristic(*weight, suspicious, false, max_weight);
183
184 let thickness = (0.5 + (*weight / max_weight).sqrt() * 1.5).min(2.0);
186
187 let bundle_strength = 0.15; let ctrl = Vector2::new(
190 src_pos.x
191 + (tgt_pos.x - src_pos.x) * 0.5
192 + (center.x - (src_pos.x + tgt_pos.x) * 0.5) * bundle_strength,
193 src_pos.y
194 + (tgt_pos.y - src_pos.y) * 0.5
195 + (center.y - (src_pos.y + tgt_pos.y) * 0.5) * bundle_strength,
196 );
197
198 self.draw_curved_edge(
200 &painter,
201 transform(src_pos),
202 transform(ctrl),
203 transform(tgt_pos),
204 Stroke::new(thickness, color),
205 );
206 }
207 }
208
209 for particle in self.particles.particles() {
211 if let (Some(src_pos), Some(tgt_pos)) = (
212 self.layout.get_position(particle.source),
213 self.layout.get_position(particle.target),
214 ) {
215 let pos = particle.position(src_pos, tgt_pos);
216 let screen_pos = transform(pos);
217
218 let glow_color = Color32::from_rgba_unmultiplied(
220 particle.color.r(),
221 particle.color.g(),
222 particle.color.b(),
223 80,
224 );
225 painter.circle_filled(screen_pos, particle.size * 2.0 * self.zoom, glow_color);
226 painter.circle_filled(screen_pos, particle.size * self.zoom, particle.color);
227 }
228 }
229
230 self.hovered_node = None;
232 for node in self.layout.nodes() {
233 let pos = transform(node.position);
234 let radius = self.theme.node_radius * self.zoom;
235
236 let mouse_pos = response.hover_pos().unwrap_or(Pos2::ZERO);
238 let distance = (pos - mouse_pos).length();
239 if distance < radius {
240 self.hovered_node = Some(node.index);
241 }
242
243 let base_color = self.theme.account_color(node.account_type);
245 let color = if Some(node.index) == self.selected_node {
246 self.theme.accent
247 } else if Some(node.index) == self.hovered_node {
248 Color32::from_rgb(
249 (base_color.r() as u16 + 40).min(255) as u8,
250 (base_color.g() as u16 + 40).min(255) as u8,
251 (base_color.b() as u16 + 40).min(255) as u8,
252 )
253 } else {
254 base_color
255 };
256
257 painter.circle_filled(pos, radius, color);
259 painter.circle_stroke(pos, radius, Stroke::new(2.0, Color32::WHITE));
260
261 if self.show_risk {
263 if let Some(account) = network.accounts.iter().find(|a| a.index == node.index) {
264 if account.risk_score > 0.5 {
265 let risk_color = self.severity_color(account.risk_score);
266 painter.circle_filled(
267 Pos2::new(pos.x + radius * 0.7, pos.y - radius * 0.7),
268 6.0 * self.zoom,
269 risk_color,
270 );
271 }
272 }
273 }
274
275 if self.show_labels && self.zoom > 0.5 {
277 if let Some(metadata) = network.account_metadata.get(&node.index) {
278 let label_pos = Pos2::new(pos.x, pos.y + radius + 10.0);
279 painter.text(
280 label_pos,
281 egui::Align2::CENTER_TOP,
282 &metadata.name,
283 egui::FontId::proportional(12.0 * self.zoom),
284 self.theme.text_primary,
285 );
286 }
287 }
288 }
289
290 self.draw_legend(&painter, rect, network);
292
293 response
294 }
295
296 fn handle_input(&mut self, ui: &egui::Ui, response: &Response, rect: Rect) {
298 if response.dragged_by(egui::PointerButton::Middle)
300 || response.dragged_by(egui::PointerButton::Secondary)
301 {
302 self.pan += response.drag_delta() / self.zoom;
303 }
304
305 if response.hovered() {
307 let scroll = ui.input(|i| i.raw_scroll_delta.y);
308 if scroll != 0.0 {
309 let zoom_delta = 1.0 + scroll * 0.001;
310 self.zoom = (self.zoom * zoom_delta).clamp(0.1, 5.0);
311 }
312 }
313
314 if response.clicked() {
316 self.selected_node = self.hovered_node;
317 }
318
319 if response.drag_started_by(egui::PointerButton::Primary) && self.hovered_node.is_some() {
321 self.dragging_node = self.hovered_node;
322 if let Some(idx) = self.dragging_node {
323 self.layout.pin_node(idx);
324 }
325 }
326
327 if let Some(idx) = self.dragging_node {
328 if response.dragged_by(egui::PointerButton::Primary) {
329 let mouse_pos = response.hover_pos().unwrap_or(Pos2::ZERO);
330 let canvas_pos = Vector2::new(
331 (mouse_pos.x - rect.left()) / self.zoom - self.pan.x,
332 (mouse_pos.y - rect.top()) / self.zoom - self.pan.y,
333 );
334 self.layout.set_position(idx, canvas_pos);
335 }
336
337 if response.drag_stopped() {
338 self.layout.unpin_node(idx);
339 self.dragging_node = None;
340 }
341 }
342 }
343
344 fn draw_grid(&self, painter: &egui::Painter, rect: Rect) {
346 let grid_spacing = 50.0 * self.zoom;
347 let grid_color = self.theme.grid_color;
348
349 let start_x = rect.left() + (self.pan.x * self.zoom) % grid_spacing;
351 let mut x = start_x;
352 while x < rect.right() {
353 painter.line_segment(
354 [Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
355 Stroke::new(1.0, grid_color),
356 );
357 x += grid_spacing;
358 }
359
360 let start_y = rect.top() + (self.pan.y * self.zoom) % grid_spacing;
362 let mut y = start_y;
363 while y < rect.bottom() {
364 painter.line_segment(
365 [Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)],
366 Stroke::new(1.0, grid_color),
367 );
368 y += grid_spacing;
369 }
370 }
371
372 fn draw_curved_edge(
374 &self,
375 painter: &egui::Painter,
376 src: Pos2,
377 ctrl: Pos2,
378 tgt: Pos2,
379 stroke: Stroke,
380 ) {
381 let segments = 16;
383 let mut prev = src;
384
385 for i in 1..=segments {
386 let t = i as f32 / segments as f32;
387 let u = 1.0 - t;
389 let x = u * u * src.x + 2.0 * u * t * ctrl.x + t * t * tgt.x;
390 let y = u * u * src.y + 2.0 * u * t * ctrl.y + t * t * tgt.y;
391 let curr = Pos2::new(x, y);
392
393 painter.line_segment([prev, curr], stroke);
394 prev = curr;
395 }
396 }
397
398 #[allow(dead_code)]
400 fn draw_arrow(&self, painter: &egui::Painter, src: Pos2, tgt: Pos2, stroke: Stroke) {
401 painter.line_segment([src, tgt], stroke);
403
404 let dir = (tgt - src).normalized();
406 let perp = Vec2::new(-dir.y, dir.x);
407 let arrow_size = 8.0 * self.zoom;
408 let tip = tgt - dir * (self.theme.node_radius * self.zoom);
409
410 let arrow_points = [
411 tip,
412 tip - dir * arrow_size + perp * arrow_size * 0.5,
413 tip - dir * arrow_size - perp * arrow_size * 0.5,
414 ];
415
416 painter.add(egui::Shape::convex_polygon(
417 arrow_points.to_vec(),
418 stroke.color,
419 Stroke::NONE,
420 ));
421 }
422
423 fn severity_color(&self, score: f32) -> Color32 {
425 if score > 0.8 {
426 self.theme.alert_critical
427 } else if score > 0.6 {
428 self.theme.alert_high
429 } else if score > 0.4 {
430 self.theme.alert_medium
431 } else {
432 self.theme.alert_low
433 }
434 }
435
436 fn draw_legend(&self, painter: &egui::Painter, rect: Rect, network: &AccountingNetwork) {
438 let legend_x = rect.right() - 130.0;
439 let legend_y = rect.top() + 15.0;
440 let section_spacing = 12.0;
441 let line_height = 16.0;
442
443 let account_entries = [
445 ("Asset", self.theme.asset_color),
446 ("Liability", self.theme.liability_color),
447 ("Equity", self.theme.equity_color),
448 ("Revenue", self.theme.revenue_color),
449 ("Expense", self.theme.expense_color),
450 ];
451
452 let flow_entries = [
454 ("Normal Flow", self.theme.flow_normal),
455 ("Suspicious", self.theme.flow_suspicious),
456 ];
457
458 let status_entries = [
460 ("Low Risk", self.theme.alert_low),
461 ("Medium Risk", self.theme.alert_medium),
462 ("High Risk", self.theme.alert_high),
463 ("Critical", self.theme.alert_critical),
464 ];
465
466 let total_height = 18.0 + account_entries.len() as f32 * line_height +
469 section_spacing +
470 18.0 + flow_entries.len() as f32 * line_height +
472 section_spacing +
473 18.0 + status_entries.len() as f32 * line_height +
475 section_spacing +
476 45.0; let bg_rect = Rect::from_min_size(
480 Pos2::new(legend_x - 10.0, legend_y - 5.0),
481 Vec2::new(125.0, total_height),
482 );
483 painter.rect_filled(
484 bg_rect,
485 6.0,
486 Color32::from_rgba_unmultiplied(15, 15, 25, 220),
487 );
488 painter.rect_stroke(
489 bg_rect,
490 6.0,
491 Stroke::new(1.0, Color32::from_rgb(60, 60, 80)),
492 );
493
494 let mut y = legend_y;
495
496 painter.text(
498 Pos2::new(legend_x, y),
499 egui::Align2::LEFT_TOP,
500 "Accounts",
501 egui::FontId::proportional(11.0),
502 self.theme.text_secondary,
503 );
504 y += 14.0;
505
506 for (label, color) in &account_entries {
508 painter.circle_filled(Pos2::new(legend_x + 5.0, y + 6.0), 5.0, *color);
509 painter.text(
510 Pos2::new(legend_x + 16.0, y),
511 egui::Align2::LEFT_TOP,
512 *label,
513 egui::FontId::proportional(10.0),
514 self.theme.text_primary,
515 );
516 y += line_height;
517 }
518
519 y += section_spacing - 4.0;
520
521 painter.text(
523 Pos2::new(legend_x, y),
524 egui::Align2::LEFT_TOP,
525 "Flows",
526 egui::FontId::proportional(11.0),
527 self.theme.text_secondary,
528 );
529 y += 14.0;
530
531 for (label, color) in &flow_entries {
533 painter.line_segment(
534 [
535 Pos2::new(legend_x, y + 6.0),
536 Pos2::new(legend_x + 12.0, y + 6.0),
537 ],
538 Stroke::new(3.0, *color),
539 );
540 painter.text(
541 Pos2::new(legend_x + 18.0, y),
542 egui::Align2::LEFT_TOP,
543 *label,
544 egui::FontId::proportional(10.0),
545 self.theme.text_primary,
546 );
547 y += line_height;
548 }
549
550 y += section_spacing - 4.0;
551
552 painter.text(
554 Pos2::new(legend_x, y),
555 egui::Align2::LEFT_TOP,
556 "Risk Level",
557 egui::FontId::proportional(11.0),
558 self.theme.text_secondary,
559 );
560 y += 14.0;
561
562 for (label, color) in &status_entries {
564 painter.rect_filled(
565 Rect::from_min_size(Pos2::new(legend_x, y + 2.0), Vec2::new(10.0, 10.0)),
566 2.0,
567 *color,
568 );
569 painter.text(
570 Pos2::new(legend_x + 16.0, y),
571 egui::Align2::LEFT_TOP,
572 *label,
573 egui::FontId::proportional(10.0),
574 self.theme.text_primary,
575 );
576 y += line_height;
577 }
578
579 y += section_spacing;
580
581 painter.line_segment(
583 [Pos2::new(legend_x, y), Pos2::new(legend_x + 100.0, y)],
584 Stroke::new(1.0, Color32::from_rgb(60, 60, 80)),
585 );
586 y += 8.0;
587
588 painter.text(
590 Pos2::new(legend_x, y),
591 egui::Align2::LEFT_TOP,
592 format!("Nodes: {}", network.accounts.len()),
593 egui::FontId::proportional(10.0),
594 self.theme.text_secondary,
595 );
596 y += 14.0;
597
598 painter.text(
600 Pos2::new(legend_x, y),
601 egui::Align2::LEFT_TOP,
602 format!("Edges: {}", network.flows.len()),
603 egui::FontId::proportional(10.0),
604 self.theme.text_secondary,
605 );
606 y += 14.0;
607
608 painter.text(
610 Pos2::new(legend_x, y),
611 egui::Align2::LEFT_TOP,
612 format!("Particles: {}", self.particles.count()),
613 egui::FontId::proportional(10.0),
614 self.theme.text_secondary,
615 );
616 }
617
618 pub fn reset_view(&mut self) {
620 self.zoom = 1.0;
621 self.pan = Vec2::ZERO;
622 }
623}
624
625impl Default for NetworkCanvas {
626 fn default() -> Self {
627 Self::new()
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn test_canvas_creation() {
637 let canvas = NetworkCanvas::new();
638 assert_eq!(canvas.zoom, 1.0);
639 assert!(canvas.show_labels);
640 }
641}