Skip to main content

ringkernel_procint/gui/canvas/
timeline_canvas.rs

1//! Timeline canvas for partial order visualization.
2//!
3//! Shows concurrent activities and precedence relationships.
4
5use super::Camera;
6use crate::gui::Theme;
7use crate::models::GpuPartialOrderTrace;
8use eframe::egui::{self, Color32, Pos2, Rect, Rounding, Stroke, Vec2};
9use std::collections::HashMap;
10
11/// Timeline canvas for partial order visualization.
12pub struct TimelineCanvas {
13    /// Camera for pan/zoom.
14    pub camera: Camera,
15    /// Activity colors.
16    activity_colors: HashMap<u32, Color32>,
17    /// Activity names.
18    activity_names: HashMap<u32, String>,
19    /// Selected trace.
20    selected_trace: Option<u64>,
21    /// Pixels per second (base scale before zoom).
22    pixels_per_sec: f32,
23}
24
25impl Default for TimelineCanvas {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl TimelineCanvas {
32    /// Create a new timeline canvas.
33    pub fn new() -> Self {
34        Self {
35            camera: Camera::default(),
36            activity_colors: HashMap::new(),
37            activity_names: HashMap::new(),
38            selected_trace: None,
39            pixels_per_sec: 0.01, // 0.01 pixels per second (activities are minutes/hours long)
40        }
41    }
42
43    /// Set activity names.
44    pub fn set_activity_names(&mut self, names: HashMap<u32, String>) {
45        self.activity_names = names;
46    }
47
48    /// Generate colors for activities.
49    fn ensure_colors(&mut self, activities: &[u32]) {
50        let palette = [
51            Color32::from_rgb(59, 130, 246), // Blue
52            Color32::from_rgb(34, 197, 94),  // Green
53            Color32::from_rgb(239, 68, 68),  // Red
54            Color32::from_rgb(168, 85, 247), // Purple
55            Color32::from_rgb(234, 179, 8),  // Yellow
56            Color32::from_rgb(236, 72, 153), // Pink
57            Color32::from_rgb(20, 184, 166), // Teal
58            Color32::from_rgb(249, 115, 22), // Orange
59        ];
60
61        for (i, &activity_id) in activities.iter().enumerate() {
62            self.activity_colors
63                .entry(activity_id)
64                .or_insert_with(|| palette[i % palette.len()]);
65        }
66    }
67
68    /// Format time in human-readable format.
69    fn format_time(seconds: f32) -> String {
70        if seconds >= 3600.0 {
71            let hours = seconds / 3600.0;
72            format!("{:.1}h", hours)
73        } else if seconds >= 60.0 {
74            let mins = seconds / 60.0;
75            format!("{:.1}m", mins)
76        } else {
77            format!("{:.0}s", seconds)
78        }
79    }
80
81    /// Render the timeline.
82    pub fn render(
83        &mut self,
84        ui: &mut egui::Ui,
85        theme: &Theme,
86        traces: &[GpuPartialOrderTrace],
87        rect: Rect,
88    ) {
89        let painter = ui.painter_at(rect);
90
91        // Handle input
92        self.handle_input(ui, rect);
93
94        // Update camera
95        self.camera.update(ui.ctx().input(|i| i.stable_dt));
96
97        // Draw background grid (with camera transform)
98        self.draw_grid(&painter, theme, rect);
99
100        // Draw header with trace count
101        painter.text(
102            Pos2::new(rect.left() + 10.0, rect.top() + 15.0),
103            egui::Align2::LEFT_CENTER,
104            format!("Partial Order Timeline - {} traces", traces.len()),
105            egui::FontId::proportional(14.0),
106            theme.text,
107        );
108
109        // Draw traces
110        let trace_height = 80.0;
111        let header_height = 40.0;
112        let mut y_offset = header_height;
113
114        if traces.is_empty() {
115            painter.text(
116                rect.center(),
117                egui::Align2::CENTER_CENTER,
118                "No partial order traces yet.\nStart the pipeline to see execution patterns.",
119                egui::FontId::proportional(14.0),
120                theme.text_muted,
121            );
122        } else {
123            for trace in traces.iter().take(6) {
124                // Limit to 6 traces for performance
125                if trace.activity_count > 0 {
126                    self.draw_trace(&painter, theme, trace, rect, y_offset, trace_height);
127                    y_offset += trace_height + 10.0;
128                }
129            }
130        }
131
132        // Draw time axis at the top
133        self.draw_time_axis(&painter, theme, rect);
134    }
135
136    /// Handle input events.
137    fn handle_input(&mut self, ui: &mut egui::Ui, rect: Rect) {
138        let response = ui.interact(
139            rect,
140            ui.id().with("timeline_canvas"),
141            egui::Sense::click_and_drag(),
142        );
143
144        // Pan
145        if response.dragged() {
146            self.camera.pan_by(response.drag_delta());
147        }
148
149        // Zoom with scroll wheel
150        let scroll = ui.ctx().input(|i| i.raw_scroll_delta.y);
151        if scroll != 0.0 {
152            if let Some(pointer) = ui.ctx().input(|i| i.pointer.hover_pos()) {
153                if rect.contains(pointer) {
154                    // Zoom centered on mouse position
155                    let zoom_factor = 1.0 + scroll * 0.002;
156                    let new_zoom = (self.camera.zoom * zoom_factor).clamp(0.1, 10.0);
157
158                    // Adjust offset to zoom toward mouse position
159                    let mouse_rel = pointer.x - rect.left();
160                    let scale_change = new_zoom / self.camera.zoom;
161                    self.camera.offset.x =
162                        mouse_rel - (mouse_rel - self.camera.offset.x) * scale_change;
163
164                    self.camera.zoom = new_zoom;
165                }
166            }
167        }
168
169        // Reset on double-click
170        if response.double_clicked() {
171            self.camera.reset();
172            self.pixels_per_sec = 0.01;
173        }
174    }
175
176    /// Draw background grid.
177    fn draw_grid(&self, painter: &egui::Painter, theme: &Theme, rect: Rect) {
178        let grid_color = theme.panel_bg.linear_multiply(1.5);
179
180        // Calculate time range visible
181        let effective_scale = self.pixels_per_sec * self.camera.zoom;
182
183        // Grid step in seconds (adaptive based on pixels per grid line)
184        // Aim for ~100 pixels between grid lines
185        let target_pixels = 100.0;
186        let raw_step = target_pixels / effective_scale;
187
188        // Round to nice values: 1min, 5min, 10min, 30min, 1h, 2h, 6h, 12h, 1day
189        let grid_step_secs = if raw_step < 60.0 {
190            60.0 // 1 minute
191        } else if raw_step < 300.0 {
192            300.0 // 5 minutes
193        } else if raw_step < 600.0 {
194            600.0 // 10 minutes
195        } else if raw_step < 1800.0 {
196            1800.0 // 30 minutes
197        } else if raw_step < 3600.0 {
198            3600.0 // 1 hour
199        } else if raw_step < 7200.0 {
200            7200.0 // 2 hours
201        } else if raw_step < 21600.0 {
202            21600.0 // 6 hours
203        } else if raw_step < 43200.0 {
204            43200.0 // 12 hours
205        } else {
206            86400.0 // 1 day
207        };
208
209        let start_time_secs = (-self.camera.offset.x / effective_scale).max(0.0);
210        let end_time_secs = start_time_secs + rect.width() / effective_scale;
211
212        // Vertical lines (time markers)
213        let first_line = (start_time_secs / grid_step_secs).floor() as i64 * grid_step_secs as i64;
214        let mut t = first_line as f32;
215
216        while t <= end_time_secs {
217            let x = rect.left() + self.camera.offset.x + t * effective_scale;
218            if x >= rect.left() && x <= rect.right() {
219                painter.line_segment(
220                    [Pos2::new(x, rect.top() + 30.0), Pos2::new(x, rect.bottom())],
221                    Stroke::new(1.0, grid_color),
222                );
223            }
224            t += grid_step_secs;
225        }
226
227        // Horizontal lines (trace separators)
228        for i in 0..8 {
229            let y = rect.top() + 40.0 + i as f32 * 90.0;
230            if y < rect.bottom() {
231                painter.line_segment(
232                    [Pos2::new(rect.left(), y), Pos2::new(rect.right(), y)],
233                    Stroke::new(1.0, grid_color),
234                );
235            }
236        }
237    }
238
239    /// Draw a single trace with actual time-based positioning.
240    fn draw_trace(
241        &mut self,
242        painter: &egui::Painter,
243        theme: &Theme,
244        trace: &GpuPartialOrderTrace,
245        rect: Rect,
246        y_offset: f32,
247        height: f32,
248    ) {
249        // Collect activities
250        let activities: Vec<u32> = trace.activity_ids[..trace.activity_count as usize].to_vec();
251        if activities.is_empty() {
252            return;
253        }
254        self.ensure_colors(&activities);
255
256        // Calculate total duration in seconds from timestamps
257        let total_duration_ms = trace
258            .end_time
259            .physical_ms
260            .saturating_sub(trace.start_time.physical_ms);
261        let total_duration_secs = (total_duration_ms as f32 / 1000.0).max(1.0);
262
263        // Layout parameters
264        let label_width = 90.0;
265        let row_height = (height / activities.len() as f32).min(14.0);
266        let effective_scale = self.pixels_per_sec * self.camera.zoom;
267        let timeline_x_start = rect.left() + label_width;
268
269        // Draw case ID label
270        painter.text(
271            Pos2::new(rect.left() + 5.0, rect.top() + y_offset),
272            egui::Align2::LEFT_CENTER,
273            format!("Case {}", trace.case_id % 10000),
274            egui::FontId::proportional(10.0),
275            theme.text,
276        );
277
278        // Draw activity count
279        painter.text(
280            Pos2::new(rect.left() + 5.0, rect.top() + y_offset + 12.0),
281            egui::Align2::LEFT_CENTER,
282            format!("{} acts", activities.len()),
283            egui::FontId::proportional(9.0),
284            theme.text_muted,
285        );
286
287        // Draw concurrency indicator
288        if trace.max_width > 1 {
289            painter.text(
290                Pos2::new(rect.left() + 5.0, rect.top() + y_offset + 24.0),
291                egui::Align2::LEFT_CENTER,
292                format!("⇉{}", trace.max_width),
293                egui::FontId::proportional(9.0),
294                Color32::YELLOW,
295            );
296        }
297
298        // Draw total duration
299        painter.text(
300            Pos2::new(rect.left() + label_width - 5.0, rect.top() + y_offset),
301            egui::Align2::RIGHT_CENTER,
302            Self::format_time(total_duration_secs),
303            egui::FontId::proportional(9.0),
304            theme.text_muted,
305        );
306
307        let bars_y_start = rect.top() + y_offset + 5.0;
308
309        // Draw timeline background
310        let timeline_rect = Rect::from_min_size(
311            Pos2::new(timeline_x_start, bars_y_start),
312            Vec2::new(
313                rect.width() - label_width - 10.0,
314                activities.len() as f32 * row_height,
315            ),
316        );
317        painter.rect_filled(
318            timeline_rect,
319            Rounding::same(2.0),
320            theme.panel_bg.linear_multiply(0.6),
321        );
322
323        // Draw activity bars positioned by actual time (with camera transform)
324        for (i, &activity_id) in activities.iter().enumerate() {
325            let color = self
326                .activity_colors
327                .get(&activity_id)
328                .copied()
329                .unwrap_or(theme.accent);
330            let row_y = bars_y_start + i as f32 * row_height;
331
332            // Get timing data (in seconds)
333            let start_secs = trace.activity_start_secs[i] as f32;
334            let duration_secs = trace.activity_duration_secs[i] as f32;
335
336            // Calculate bar position with camera offset
337            let bar_x = timeline_x_start + self.camera.offset.x + start_secs * effective_scale;
338            let bar_w = (duration_secs * effective_scale).max(8.0); // Minimum 8px for visibility
339
340            // Skip if bar is completely off-screen
341            if bar_x + bar_w < rect.left() || bar_x > rect.right() {
342                continue;
343            }
344
345            // Clip bar to visible area
346            let clipped_x = bar_x.max(timeline_x_start);
347            let clipped_w = (bar_x + bar_w).min(rect.right() - 5.0) - clipped_x;
348
349            if clipped_w <= 0.0 {
350                continue;
351            }
352
353            let bar_rect = Rect::from_min_size(
354                Pos2::new(clipped_x, row_y + 1.0),
355                Vec2::new(clipped_w, row_height - 2.0),
356            );
357
358            // Check if concurrent
359            let is_concurrent = self.has_concurrent_activities(trace, i);
360            let fill_color = if is_concurrent {
361                color
362            } else {
363                color.linear_multiply(0.7)
364            };
365
366            painter.rect_filled(bar_rect, Rounding::same(2.0), fill_color);
367
368            // Yellow border for concurrent activities
369            if is_concurrent {
370                painter.rect_stroke(
371                    bar_rect,
372                    Rounding::same(2.0),
373                    Stroke::new(1.5, Color32::YELLOW),
374                );
375            }
376
377            // Activity label inside bar (if wide enough)
378            let name = self
379                .activity_names
380                .get(&activity_id)
381                .map(|n| if n.len() > 8 { &n[..8] } else { n })
382                .unwrap_or("?");
383
384            if clipped_w > 40.0 && bar_rect.left() >= timeline_x_start {
385                painter.text(
386                    Pos2::new(bar_rect.left() + 3.0, bar_rect.center().y),
387                    egui::Align2::LEFT_CENTER,
388                    name,
389                    egui::FontId::proportional(8.0),
390                    Color32::WHITE,
391                );
392            }
393
394            // Duration label at right of bar (if space)
395            if clipped_w > 60.0 {
396                painter.text(
397                    Pos2::new(bar_rect.right() - 2.0, bar_rect.center().y),
398                    egui::Align2::RIGHT_CENTER,
399                    Self::format_time(duration_secs),
400                    egui::FontId::proportional(7.0),
401                    Color32::WHITE.linear_multiply(0.8),
402                );
403            }
404        }
405
406        // Draw arrows for direct precedence relationships
407        for i in 0..activities.len() {
408            for j in 0..activities.len() {
409                if i != j && trace.precedes(i, j) {
410                    // Check if direct precedence (no intermediate activity)
411                    let is_direct = !(0..activities.len())
412                        .any(|k| k != i && k != j && trace.precedes(i, k) && trace.precedes(k, j));
413
414                    if is_direct {
415                        let start_i = trace.activity_start_secs[i] as f32;
416                        let dur_i = trace.activity_duration_secs[i] as f32;
417                        let start_j = trace.activity_start_secs[j] as f32;
418
419                        let x1 = timeline_x_start
420                            + self.camera.offset.x
421                            + (start_i + dur_i) * effective_scale;
422                        let x2 =
423                            timeline_x_start + self.camera.offset.x + start_j * effective_scale;
424                        let y1 = bars_y_start + i as f32 * row_height + row_height / 2.0;
425                        let y2 = bars_y_start + j as f32 * row_height + row_height / 2.0;
426
427                        // Only draw if both endpoints are visible
428                        if x1 >= timeline_x_start && x2 <= rect.right() {
429                            painter.line_segment(
430                                [Pos2::new(x1, y1), Pos2::new(x2, y2)],
431                                Stroke::new(1.0, theme.accent.linear_multiply(0.3)),
432                            );
433                        }
434                    }
435                }
436            }
437        }
438    }
439
440    /// Compute parallel execution layers for activities.
441    /// Activities that can run in parallel are assigned the same layer.
442    #[allow(dead_code)]
443    fn compute_parallel_layers(&self, trace: &GpuPartialOrderTrace) -> Vec<usize> {
444        let n = trace.activity_count as usize;
445        let mut layers = vec![0usize; n];
446
447        // Topological sort with layer assignment
448        // Layer[i] = max(Layer[j] + 1) for all j that precede i
449        for _ in 0..n {
450            for i in 0..n {
451                for j in 0..n {
452                    if i != j && trace.precedes(j, i) {
453                        layers[i] = layers[i].max(layers[j] + 1);
454                    }
455                }
456            }
457        }
458
459        layers
460    }
461
462    /// Check if an activity has any concurrent activities.
463    fn has_concurrent_activities(&self, trace: &GpuPartialOrderTrace, idx: usize) -> bool {
464        for j in 0..trace.activity_count as usize {
465            if idx != j && trace.is_concurrent(idx, j) {
466                return true;
467            }
468        }
469        false
470    }
471
472    /// Draw time axis.
473    fn draw_time_axis(&self, painter: &egui::Painter, theme: &Theme, rect: Rect) {
474        let axis_y = rect.top() + 32.0;
475        let label_width = 90.0;
476        let timeline_x_start = rect.left() + label_width;
477
478        // Calculate time range visible
479        let effective_scale = self.pixels_per_sec * self.camera.zoom;
480
481        // Time step for labels (adaptive based on pixels per label)
482        // Aim for ~100 pixels between labels
483        let target_pixels = 100.0;
484        let raw_step = target_pixels / effective_scale;
485
486        // Round to nice values
487        let time_step_secs = if raw_step < 60.0 {
488            60.0 // 1 minute
489        } else if raw_step < 300.0 {
490            300.0 // 5 minutes
491        } else if raw_step < 600.0 {
492            600.0 // 10 minutes
493        } else if raw_step < 1800.0 {
494            1800.0 // 30 minutes
495        } else if raw_step < 3600.0 {
496            3600.0 // 1 hour
497        } else if raw_step < 7200.0 {
498            7200.0 // 2 hours
499        } else if raw_step < 21600.0 {
500            21600.0 // 6 hours
501        } else if raw_step < 43200.0 {
502            43200.0 // 12 hours
503        } else {
504            86400.0 // 1 day
505        };
506
507        let start_time_secs = (-self.camera.offset.x / effective_scale).max(0.0);
508        let end_time_secs = start_time_secs + (rect.width() - label_width) / effective_scale;
509
510        // Axis line
511        painter.line_segment(
512            [
513                Pos2::new(timeline_x_start, axis_y),
514                Pos2::new(rect.right(), axis_y),
515            ],
516            Stroke::new(1.0, theme.text_muted),
517        );
518
519        // Time labels
520        let first_label = (start_time_secs / time_step_secs).floor() as i64 * time_step_secs as i64;
521        let mut t = first_label as f32;
522
523        while t <= end_time_secs {
524            let x = timeline_x_start + self.camera.offset.x + t * effective_scale;
525            if x >= timeline_x_start && x <= rect.right() - 20.0 {
526                // Tick mark
527                painter.line_segment(
528                    [Pos2::new(x, axis_y - 3.0), Pos2::new(x, axis_y + 3.0)],
529                    Stroke::new(1.0, theme.text_muted),
530                );
531
532                // Label
533                painter.text(
534                    Pos2::new(x, axis_y - 8.0),
535                    egui::Align2::CENTER_BOTTOM,
536                    Self::format_time(t),
537                    egui::FontId::proportional(9.0),
538                    theme.text_muted,
539                );
540            }
541            t += time_step_secs;
542        }
543    }
544
545    /// Reset canvas.
546    pub fn reset(&mut self) {
547        self.camera.reset();
548        self.activity_colors.clear();
549        self.selected_trace = None;
550        self.pixels_per_sec = 0.01;
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn test_timeline_creation() {
560        let canvas = TimelineCanvas::new();
561        assert!(canvas.activity_colors.is_empty());
562    }
563
564    #[test]
565    fn test_format_time() {
566        assert_eq!(TimelineCanvas::format_time(30.0), "30s");
567        assert_eq!(TimelineCanvas::format_time(90.0), "1.5m");
568        assert_eq!(TimelineCanvas::format_time(3600.0), "1.0h");
569        assert_eq!(TimelineCanvas::format_time(7200.0), "2.0h");
570    }
571}