ringkernel_procint/gui/canvas/
dfg_canvas.rs

1//! Force-directed DFG canvas.
2//!
3//! Renders Directly-Follows Graph with interactive layout.
4
5use super::{Camera, NodePosition, TokenAnimation};
6use crate::gui::Theme;
7use crate::models::{DFGGraph, GpuDFGEdge, GpuDFGNode, GpuPatternMatch, PatternType};
8use eframe::egui::{self, Color32, Pos2, Rect, Stroke, Vec2};
9use std::collections::HashMap;
10
11/// DFG canvas for visualization.
12pub struct DfgCanvas {
13    /// Camera for pan/zoom.
14    pub camera: Camera,
15    /// Node positions.
16    positions: HashMap<u32, NodePosition>,
17    /// Activity names.
18    activity_names: HashMap<u32, String>,
19    /// Token animation.
20    pub tokens: TokenAnimation,
21    /// Selected node.
22    selected_node: Option<u32>,
23    /// Hovered node.
24    hovered_node: Option<u32>,
25    /// Layout iteration count.
26    layout_iterations: u32,
27    /// Patterns to highlight.
28    highlight_patterns: Vec<GpuPatternMatch>,
29}
30
31impl Default for DfgCanvas {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl DfgCanvas {
38    /// Create a new DFG canvas.
39    pub fn new() -> Self {
40        Self {
41            camera: Camera::default(),
42            positions: HashMap::new(),
43            activity_names: HashMap::new(),
44            tokens: TokenAnimation::new(),
45            selected_node: None,
46            hovered_node: None,
47            layout_iterations: 0,
48            highlight_patterns: Vec::new(),
49        }
50    }
51
52    /// Set activity names for display.
53    pub fn set_activity_names(&mut self, names: HashMap<u32, String>) {
54        self.activity_names = names;
55    }
56
57    /// Set patterns to highlight.
58    pub fn set_highlight_patterns(&mut self, patterns: Vec<GpuPatternMatch>) {
59        self.highlight_patterns = patterns;
60    }
61
62    /// Update layout from DFG.
63    pub fn update_layout(&mut self, dfg: &DFGGraph) {
64        // Initialize positions for new nodes
65        for node in dfg.nodes() {
66            if !self.positions.contains_key(&node.activity_id) {
67                let angle = self.positions.len() as f32 * 0.618 * std::f32::consts::TAU;
68                let radius = 100.0 + self.positions.len() as f32 * 30.0;
69                self.positions.insert(
70                    node.activity_id,
71                    NodePosition::new(angle.cos() * radius, angle.sin() * radius),
72                );
73            }
74        }
75
76        // Run force-directed layout iterations
77        self.apply_forces(dfg);
78    }
79
80    /// Apply force-directed layout.
81    fn apply_forces(&mut self, dfg: &DFGGraph) {
82        let nodes = dfg.nodes();
83        let edges = dfg.edges();
84
85        if nodes.is_empty() {
86            return;
87        }
88
89        // Force parameters
90        let repulsion = 5000.0;
91        let attraction = 0.01;
92        let damping = 0.9;
93        let dt = 0.3;
94
95        // Collect node IDs
96        let node_ids: Vec<u32> = nodes.iter().map(|n| n.activity_id).collect();
97
98        // Apply repulsion between all pairs
99        for i in 0..node_ids.len() {
100            for j in (i + 1)..node_ids.len() {
101                let id1 = node_ids[i];
102                let id2 = node_ids[j];
103
104                if let (Some(p1), Some(p2)) = (self.positions.get(&id1), self.positions.get(&id2)) {
105                    let dx = p2.x - p1.x;
106                    let dy = p2.y - p1.y;
107                    let dist_sq = (dx * dx + dy * dy).max(100.0);
108                    let force = repulsion / dist_sq;
109                    let dist = dist_sq.sqrt();
110
111                    let fx = force * dx / dist;
112                    let fy = force * dy / dist;
113
114                    if let Some(p) = self.positions.get_mut(&id1) {
115                        p.vx -= fx;
116                        p.vy -= fy;
117                    }
118                    if let Some(p) = self.positions.get_mut(&id2) {
119                        p.vx += fx;
120                        p.vy += fy;
121                    }
122                }
123            }
124        }
125
126        // Apply attraction along edges
127        for edge in edges {
128            if let (Some(p1), Some(p2)) = (
129                self.positions.get(&edge.source_activity),
130                self.positions.get(&edge.target_activity),
131            ) {
132                let dx = p2.x - p1.x;
133                let dy = p2.y - p1.y;
134                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
135
136                // Target distance based on edge frequency
137                let target_dist = 150.0 - (edge.frequency as f32).log2().min(50.0);
138                let force = attraction * (dist - target_dist);
139
140                let fx = force * dx / dist;
141                let fy = force * dy / dist;
142
143                let src = edge.source_activity;
144                let tgt = edge.target_activity;
145
146                if let Some(p) = self.positions.get_mut(&src) {
147                    p.vx += fx;
148                    p.vy += fy;
149                }
150                if let Some(p) = self.positions.get_mut(&tgt) {
151                    p.vx -= fx;
152                    p.vy -= fy;
153                }
154            }
155        }
156
157        // Update positions
158        for pos in self.positions.values_mut() {
159            pos.vx *= damping;
160            pos.vy *= damping;
161            pos.x += pos.vx * dt;
162            pos.y += pos.vy * dt;
163        }
164
165        self.layout_iterations += 1;
166    }
167
168    /// Render the canvas.
169    pub fn render(&mut self, ui: &mut egui::Ui, theme: &Theme, dfg: &DFGGraph, rect: Rect) {
170        let painter = ui.painter_at(rect);
171        let center = rect.center();
172
173        // Handle input
174        self.handle_input(ui, rect);
175
176        // Update camera
177        self.camera.update(ui.ctx().input(|i| i.stable_dt));
178
179        // Update tokens
180        self.tokens.update(ui.ctx().input(|i| i.stable_dt));
181
182        // Draw edges
183        for edge in dfg.edges() {
184            self.draw_edge(&painter, theme, edge, center, dfg);
185        }
186
187        // Draw tokens
188        for token in self.tokens.active_tokens() {
189            if let (Some(src_pos), Some(tgt_pos)) = (
190                self.positions.get(&token.source_activity),
191                self.positions.get(&token.target_activity),
192            ) {
193                let p1 = self.camera.world_to_screen(src_pos.pos(), center);
194                let p2 = self.camera.world_to_screen(tgt_pos.pos(), center);
195                let pos = p1 + (p2 - p1) * token.progress;
196
197                // Glow effect
198                painter.circle_filled(pos, 8.0, theme.token.linear_multiply(0.3));
199                painter.circle_filled(pos, 5.0, theme.token);
200            }
201        }
202
203        // Draw nodes
204        for node in dfg.nodes() {
205            self.draw_node(&painter, theme, node, center);
206        }
207
208        // Draw highlight patterns
209        self.draw_pattern_highlights(&painter, theme, center);
210    }
211
212    /// Handle input events.
213    fn handle_input(&mut self, ui: &mut egui::Ui, rect: Rect) {
214        let response = ui.interact(
215            rect,
216            ui.id().with("dfg_canvas"),
217            egui::Sense::click_and_drag(),
218        );
219
220        // Pan
221        if response.dragged() {
222            self.camera.pan_by(response.drag_delta());
223        }
224
225        // Zoom
226        let scroll = ui.ctx().input(|i| i.raw_scroll_delta.y);
227        if scroll != 0.0 {
228            if let Some(pointer) = ui.ctx().input(|i| i.pointer.hover_pos()) {
229                if rect.contains(pointer) {
230                    self.camera.zoom_by(scroll * 0.01, pointer, rect.center());
231                }
232            }
233        }
234
235        // Reset on double-click
236        if response.double_clicked() {
237            self.camera.reset();
238        }
239
240        // Check node hover
241        self.hovered_node = None;
242        if let Some(pointer) = ui.ctx().input(|i| i.pointer.hover_pos()) {
243            if rect.contains(pointer) {
244                let world_pos = self.camera.screen_to_world(pointer, rect.center());
245                for (id, pos) in &self.positions {
246                    let dist = (Pos2::new(pos.x, pos.y) - world_pos).length();
247                    if dist < 30.0 / self.camera.zoom {
248                        self.hovered_node = Some(*id);
249                        break;
250                    }
251                }
252            }
253        }
254
255        // Select on click
256        if response.clicked() {
257            self.selected_node = self.hovered_node;
258        }
259    }
260
261    /// Draw a single node.
262    fn draw_node(&self, painter: &egui::Painter, theme: &Theme, node: &GpuDFGNode, center: Pos2) {
263        if let Some(pos) = self.positions.get(&node.activity_id) {
264            let screen_pos = self.camera.world_to_screen(pos.pos(), center);
265
266            // Node size based on event count
267            let base_size = 20.0;
268            let size = base_size + (node.event_count as f32).log2().min(20.0) * 2.0;
269            let size = size * self.camera.zoom;
270
271            // Node color
272            let color = if node.is_start != 0 {
273                theme.node_start
274            } else if node.is_end != 0 {
275                theme.node_end
276            } else {
277                theme.node_default
278            };
279
280            // Highlight if selected or hovered
281            let is_highlighted = self.selected_node == Some(node.activity_id)
282                || self.hovered_node == Some(node.activity_id);
283
284            if is_highlighted {
285                painter.circle_filled(screen_pos, size + 4.0, color.linear_multiply(0.3));
286            }
287
288            // Node circle
289            painter.circle_filled(screen_pos, size, color);
290            painter.circle_stroke(
291                screen_pos,
292                size,
293                Stroke::new(2.0, Color32::WHITE.linear_multiply(0.3)),
294            );
295
296            // Node label
297            let name = self
298                .activity_names
299                .get(&node.activity_id)
300                .cloned()
301                .unwrap_or_else(|| format!("A{}", node.activity_id));
302
303            let label_pos = screen_pos + Vec2::new(0.0, size + 12.0);
304            painter.text(
305                label_pos,
306                egui::Align2::CENTER_TOP,
307                &name,
308                egui::FontId::proportional(11.0 * self.camera.zoom.sqrt()),
309                theme.text,
310            );
311
312            // Event count badge
313            if node.event_count > 0 {
314                let badge_pos = screen_pos + Vec2::new(size * 0.7, -size * 0.7);
315                painter.circle_filled(badge_pos, 10.0 * self.camera.zoom, theme.accent);
316                painter.text(
317                    badge_pos,
318                    egui::Align2::CENTER_CENTER,
319                    format!("{}", node.event_count),
320                    egui::FontId::proportional(8.0 * self.camera.zoom),
321                    Color32::WHITE,
322                );
323            }
324        }
325    }
326
327    /// Draw a single edge.
328    fn draw_edge(
329        &self,
330        painter: &egui::Painter,
331        theme: &Theme,
332        edge: &GpuDFGEdge,
333        center: Pos2,
334        dfg: &DFGGraph,
335    ) {
336        if let (Some(src_pos), Some(tgt_pos)) = (
337            self.positions.get(&edge.source_activity),
338            self.positions.get(&edge.target_activity),
339        ) {
340            let p1 = self.camera.world_to_screen(src_pos.pos(), center);
341            let p2 = self.camera.world_to_screen(tgt_pos.pos(), center);
342
343            // Edge width based on frequency
344            let width = 1.0 + (edge.frequency as f32).log2().min(4.0);
345
346            // Edge color based on duration
347            let avg_duration = dfg.nodes().iter().map(|n| n.avg_duration_ms).sum::<f32>()
348                / dfg.nodes().len().max(1) as f32;
349            let color = theme.edge_duration_color(edge.avg_duration_ms, avg_duration);
350
351            // Draw edge
352            painter.line_segment([p1, p2], Stroke::new(width, color.linear_multiply(0.7)));
353
354            // Draw arrowhead
355            let dir = (p2 - p1).normalized();
356            let arrow_size = 8.0 * self.camera.zoom;
357            let arrow_pos = p2 - dir * 25.0 * self.camera.zoom;
358
359            let perp = Vec2::new(-dir.y, dir.x);
360            let arrow_p1 = arrow_pos - dir * arrow_size + perp * arrow_size * 0.5;
361            let arrow_p2 = arrow_pos - dir * arrow_size - perp * arrow_size * 0.5;
362
363            painter.add(egui::Shape::convex_polygon(
364                vec![arrow_pos, arrow_p1, arrow_p2],
365                color,
366                Stroke::NONE,
367            ));
368
369            // Draw frequency label on edge
370            if edge.frequency > 1 {
371                let mid = p1 + (p2 - p1) * 0.5;
372                let label_offset = perp * 12.0;
373                painter.text(
374                    mid + label_offset,
375                    egui::Align2::CENTER_CENTER,
376                    format!("{}", edge.frequency),
377                    egui::FontId::proportional(9.0),
378                    theme.text_muted,
379                );
380            }
381        }
382    }
383
384    /// Draw pattern highlights.
385    fn draw_pattern_highlights(&self, painter: &egui::Painter, theme: &Theme, center: Pos2) {
386        for pattern in &self.highlight_patterns {
387            let pattern_type = pattern.get_pattern_type();
388            let color = match pattern_type {
389                PatternType::Bottleneck => theme.bottleneck,
390                PatternType::Loop | PatternType::Rework => theme.loop_highlight,
391                PatternType::LongRunning => theme.warning,
392                _ => theme.accent,
393            };
394
395            // Highlight each activity in the pattern
396            for &activity_id in pattern.activities() {
397                if let Some(pos) = self.positions.get(&activity_id) {
398                    let screen_pos = self.camera.world_to_screen(pos.pos(), center);
399                    let size = 35.0 * self.camera.zoom;
400
401                    // Pulsing glow effect
402                    let time = painter.ctx().input(|i| i.time);
403                    let alpha = ((time * 2.0).sin() * 0.3 + 0.5) as f32;
404
405                    painter.circle_stroke(
406                        screen_pos,
407                        size,
408                        Stroke::new(3.0, color.linear_multiply(alpha)),
409                    );
410                    painter.circle_stroke(
411                        screen_pos,
412                        size + 5.0,
413                        Stroke::new(2.0, color.linear_multiply(alpha * 0.5)),
414                    );
415                }
416            }
417        }
418    }
419
420    /// Reset layout.
421    pub fn reset(&mut self) {
422        self.positions.clear();
423        self.layout_iterations = 0;
424        self.tokens.clear();
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_canvas_creation() {
434        let canvas = DfgCanvas::new();
435        assert!(canvas.positions.is_empty());
436    }
437}