Skip to main content

ringkernel_accnet/gui/
canvas.rs

1//! Network graph canvas with interactive visualization.
2//!
3//! Renders the accounting network as an interactive force-directed graph
4//! with flow particle animations.
5
6use 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
15/// Interactive network canvas.
16pub struct NetworkCanvas {
17    /// Force-directed layout engine.
18    pub layout: ForceDirectedLayout,
19    /// Particle animation system.
20    pub particles: ParticleSystem,
21    /// Visual theme.
22    pub theme: AccNetTheme,
23    /// Canvas zoom level.
24    pub zoom: f32,
25    /// Canvas pan offset.
26    pub pan: Vec2,
27    /// Currently selected node.
28    pub selected_node: Option<u16>,
29    /// Currently hovered node.
30    pub hovered_node: Option<u16>,
31    /// Node being dragged.
32    dragging_node: Option<u16>,
33    /// Show labels.
34    pub show_labels: bool,
35    /// Show risk indicators.
36    pub show_risk: bool,
37    /// Animate layout.
38    pub animate_layout: bool,
39    /// Last frame time for delta calculation.
40    last_frame: std::time::Instant,
41}
42
43impl NetworkCanvas {
44    /// Create a new network canvas.
45    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    /// Initialize from a network.
63    pub fn initialize(&mut self, network: &AccountingNetwork) {
64        self.layout.initialize(network);
65        self.refresh_particles(network);
66    }
67
68    /// Refresh particles without resetting layout.
69    /// Call this periodically to update flow animations while preserving node positions.
70    pub fn refresh_particles(&mut self, network: &AccountingNetwork) {
71        self.particles.clear();
72
73        // Queue recent flows for particle animation (limit to last 500 for performance)
74        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    /// Update layout and animations.
95    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        // Update layout
101        if self.animate_layout && !self.layout.converged {
102            self.layout.iterate(5);
103        }
104
105        // Update particles
106        self.particles.update(dt);
107    }
108
109    /// Render the canvas.
110    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        // Only resize layout when canvas size actually changes
116        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        // Handle input
125        self.handle_input(ui, &response, rect);
126
127        // Draw background
128        painter.rect_filled(rect, 0.0, self.theme.canvas_bg);
129
130        // Draw grid
131        self.draw_grid(&painter, rect);
132
133        // Transform helper
134        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        // Calculate max edge weight for normalization
141        let max_weight = self
142            .layout
143            .edges()
144            .iter()
145            .map(|(_, _, w)| *w)
146            .fold(1.0f32, f32::max);
147
148        // Calculate center for edge bundling
149        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        // Draw edges with futuristic styling and edge bundling
167        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                // Check if this edge involves suspicious activity
173                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                // Get futuristic color with transparency
180                let color = self
181                    .theme
182                    .edge_color_futuristic(*weight, suspicious, false, max_weight);
183
184                // Thin edges: 0.5 to 2.0 based on weight
185                let thickness = (0.5 + (*weight / max_weight).sqrt() * 1.5).min(2.0);
186
187                // Edge bundling: curve edges toward center
188                let bundle_strength = 0.15; // How much to curve toward center
189                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                // Draw curved edge (quadratic bezier)
199                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        // Draw particles
210        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                // Glow effect
219                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        // Draw nodes
231        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            // Check hover
237            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            // Node color
244            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            // Draw node
258            painter.circle_filled(pos, radius, color);
259            painter.circle_stroke(pos, radius, Stroke::new(2.0, Color32::WHITE));
260
261            // Risk indicator
262            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            // Label
276            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        // Draw legend
291        self.draw_legend(&painter, rect, network);
292
293        response
294    }
295
296    /// Handle user input.
297    fn handle_input(&mut self, ui: &egui::Ui, response: &Response, rect: Rect) {
298        // Pan with middle mouse or right drag
299        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        // Zoom with scroll
306        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        // Node selection with left click
315        if response.clicked() {
316            self.selected_node = self.hovered_node;
317        }
318
319        // Node dragging
320        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    /// Draw background grid.
345    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        // Vertical lines
350        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        // Horizontal lines
361        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    /// Draw a curved edge using quadratic Bezier curve.
373    fn draw_curved_edge(
374        &self,
375        painter: &egui::Painter,
376        src: Pos2,
377        ctrl: Pos2,
378        tgt: Pos2,
379        stroke: Stroke,
380    ) {
381        // Approximate quadratic Bezier with line segments for smooth curve
382        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            // Quadratic Bezier: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
388            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    /// Draw an arrow from src to tgt.
399    #[allow(dead_code)]
400    fn draw_arrow(&self, painter: &egui::Painter, src: Pos2, tgt: Pos2, stroke: Stroke) {
401        // Line
402        painter.line_segment([src, tgt], stroke);
403
404        // Arrowhead
405        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    /// Get color for severity level.
424    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    /// Draw the enhanced legend with account types, flow types, and status indicators.
437    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        // Account types section
444        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        // Flow types section
453        let flow_entries = [
454            ("Normal Flow", self.theme.flow_normal),
455            ("Suspicious", self.theme.flow_suspicious),
456        ];
457
458        // Status indicators section
459        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        // Calculate total height
467        let total_height = 18.0 + // "Accounts" header
468            account_entries.len() as f32 * line_height +
469            section_spacing +
470            18.0 + // "Flows" header
471            flow_entries.len() as f32 * line_height +
472            section_spacing +
473            18.0 + // "Status" header
474            status_entries.len() as f32 * line_height +
475            section_spacing +
476            45.0; // Stats section
477
478        // Background
479        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        // Account types header
497        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        // Account type entries
507        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        // Flow types header
522        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        // Flow type entries (with line indicator instead of circle)
532        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        // Status indicators header
553        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        // Status entries (with small square indicator)
563        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        // Live stats section
582        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        // Node count
589        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        // Edge count
599        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        // Particle count
609        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    /// Reset view.
619    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}