ringkernel_procint/gui/
app.rs

1//! Main application state and rendering.
2
3use super::canvas::{DfgCanvas, TimelineCanvas};
4use super::panels::{ConformancePanel, DfgPanel, FabricPanel, KpiPanel, PatternsPanel};
5use super::Theme;
6use crate::actors::PipelineCoordinator;
7use crate::cuda::GpuStatus;
8use crate::fabric::{PipelineConfig, SectorTemplate};
9use crate::models::DFGGraph;
10use eframe::egui::{self, Color32, Pos2, Rect};
11use std::collections::HashMap;
12
13/// View mode for the main canvas.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ViewMode {
16    Dfg,
17    Timeline,
18    Variations,
19    Split,
20}
21
22/// Main application state.
23pub struct ProcessIntelligenceApp {
24    /// Theme.
25    pub theme: Theme,
26    /// Pipeline coordinator.
27    pub coordinator: PipelineCoordinator,
28    /// DFG canvas.
29    pub dfg_canvas: DfgCanvas,
30    /// Timeline canvas.
31    pub timeline_canvas: TimelineCanvas,
32    /// Current view mode.
33    pub view_mode: ViewMode,
34    /// Fabric control panel.
35    pub fabric_panel: FabricPanel,
36    /// KPI panel.
37    pub kpi_panel: KpiPanel,
38    /// Patterns panel.
39    pub patterns_panel: PatternsPanel,
40    /// Conformance panel.
41    pub conformance_panel: ConformancePanel,
42    /// DFG panel.
43    pub dfg_panel: DfgPanel,
44    /// Current DFG.
45    dfg: DFGGraph,
46    /// Partial order traces.
47    partial_orders: Vec<crate::models::GpuPartialOrderTrace>,
48    /// Frame counter.
49    frame_count: u64,
50    /// Show settings.
51    show_settings: bool,
52    /// Last sector (to detect changes).
53    last_sector: SectorTemplate,
54}
55
56impl Default for ProcessIntelligenceApp {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62impl ProcessIntelligenceApp {
63    /// Create a new application.
64    pub fn new() -> Self {
65        use crate::fabric::HealthcareConfig;
66        let initial_sector = SectorTemplate::Healthcare(HealthcareConfig::default());
67        let coordinator =
68            PipelineCoordinator::new(initial_sector.clone(), PipelineConfig::default());
69
70        let mut dfg_canvas = DfgCanvas::new();
71
72        // Set activity names from sector
73        let sector = coordinator.pipeline().sector();
74        let names: HashMap<u32, String> = sector
75            .activities()
76            .iter()
77            .enumerate()
78            .map(|(i, a)| ((i + 1) as u32, a.name.to_string()))
79            .collect();
80        dfg_canvas.set_activity_names(names.clone());
81
82        let mut timeline_canvas = TimelineCanvas::new();
83        timeline_canvas.set_activity_names(names);
84
85        Self {
86            theme: Theme::dark(),
87            coordinator,
88            dfg_canvas,
89            timeline_canvas,
90            view_mode: ViewMode::Dfg,
91            fabric_panel: FabricPanel::default(),
92            kpi_panel: KpiPanel::default(),
93            patterns_panel: PatternsPanel,
94            conformance_panel: ConformancePanel::default(),
95            dfg_panel: DfgPanel,
96            dfg: DFGGraph::default(),
97            partial_orders: Vec::new(),
98            frame_count: 0,
99            show_settings: false,
100            last_sector: initial_sector,
101        }
102    }
103
104    /// Run the application.
105    pub fn run(self) -> eframe::Result<()> {
106        let options = eframe::NativeOptions {
107            viewport: egui::ViewportBuilder::default()
108                .with_inner_size([1400.0, 900.0])
109                .with_min_inner_size([1000.0, 600.0])
110                .with_title("RingKernel Process Intelligence"),
111            ..Default::default()
112        };
113
114        eframe::run_native(
115            "Process Intelligence",
116            options,
117            Box::new(|cc| {
118                // Apply theme
119                let app = self;
120                app.theme.apply(&cc.egui_ctx);
121                Ok(Box::new(app))
122            }),
123        )
124    }
125}
126
127impl eframe::App for ProcessIntelligenceApp {
128    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
129        self.frame_count += 1;
130
131        // Check for sector changes and reset views
132        if self.fabric_panel.sector != self.last_sector {
133            self.last_sector = self.fabric_panel.sector.clone();
134            self.reset_views();
135        }
136
137        // Process pipeline tick
138        if self.coordinator.stats().is_running {
139            if let Some(batch) = self.coordinator.tick() {
140                // Update DFG from coordinator
141                self.dfg = self.coordinator.current_dfg().clone();
142                self.partial_orders = self.coordinator.current_partial_orders().to_vec();
143
144                // Update conformance panel
145                if let Some(conformance) = self.coordinator.current_conformance() {
146                    self.conformance_panel.update(conformance.clone());
147                }
148
149                // Spawn tokens for animation
150                // Use actual DFG edges for token animation
151                if batch.event_count > 0
152                    && self.frame_count.is_multiple_of(5)
153                    && !self.dfg.edges().is_empty()
154                {
155                    let edge_idx = (self.frame_count as usize) % self.dfg.edges().len();
156                    let edge = &self.dfg.edges()[edge_idx];
157                    self.dfg_canvas.tokens.spawn(
158                        batch.batch_id,
159                        edge.source_activity,
160                        edge.target_activity,
161                    );
162                }
163            }
164
165            // Update KPI panel
166            let kpis = self.coordinator.analytics().kpi_tracker.kpis();
167            self.kpi_panel.update(kpis);
168
169            // Request continuous repaint
170            ctx.request_repaint();
171        }
172
173        // Update canvases
174        self.dfg_canvas.update_layout(&self.dfg);
175
176        // Top panel
177        egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
178            self.render_top_bar(ui);
179        });
180
181        // Bottom status bar
182        egui::TopBottomPanel::bottom("status_bar")
183            .min_height(28.0)
184            .show(ctx, |ui| {
185                self.render_status_bar(ui);
186            });
187
188        // Left side panel
189        egui::SidePanel::left("left_panel")
190            .min_width(220.0)
191            .max_width(280.0)
192            .show(ctx, |ui| {
193                egui::ScrollArea::vertical().show(ui, |ui| {
194                    self.fabric_panel
195                        .render(ui, &self.theme, &mut self.coordinator);
196                    ui.add_space(12.0);
197                    let kpis = self.coordinator.analytics().kpi_tracker.kpis();
198                    self.kpi_panel.render(ui, &self.theme, kpis);
199                });
200            });
201
202        // Right side panel
203        egui::SidePanel::right("right_panel")
204            .min_width(200.0)
205            .max_width(260.0)
206            .show(ctx, |ui| {
207                egui::ScrollArea::vertical().show(ui, |ui| {
208                    self.patterns_panel.render(
209                        ui,
210                        &self.theme,
211                        &self.coordinator.analytics().pattern_aggregator,
212                    );
213                    ui.add_space(12.0);
214                    self.conformance_panel.render(ui, &self.theme);
215                    ui.add_space(12.0);
216                    let metrics = self.coordinator.analytics().dfg_metrics.metrics();
217                    self.dfg_panel.render(ui, &self.theme, metrics);
218                });
219            });
220
221        // Central panel (canvas)
222        egui::CentralPanel::default().show(ctx, |ui| {
223            self.render_canvas(ui);
224        });
225    }
226}
227
228impl ProcessIntelligenceApp {
229    /// Render top bar.
230    fn render_top_bar(&mut self, ui: &mut egui::Ui) {
231        ui.horizontal(|ui| {
232            ui.heading(
233                egui::RichText::new("RingKernel Process Intelligence")
234                    .strong()
235                    .color(self.theme.accent),
236            );
237
238            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
239                // Settings button
240                if ui.button("⚙").clicked() {
241                    self.show_settings = !self.show_settings;
242                }
243
244                ui.separator();
245
246                // View mode buttons
247                ui.selectable_value(&mut self.view_mode, ViewMode::Dfg, "DFG");
248                ui.selectable_value(&mut self.view_mode, ViewMode::Timeline, "Timeline");
249                ui.selectable_value(&mut self.view_mode, ViewMode::Variations, "Variants");
250                ui.selectable_value(&mut self.view_mode, ViewMode::Split, "Split");
251            });
252        });
253    }
254
255    /// Render status bar.
256    fn render_status_bar(&mut self, ui: &mut egui::Ui) {
257        ui.horizontal(|ui| {
258            let stats = self.coordinator.stats();
259            let kpis = self.coordinator.analytics().kpi_tracker.kpis();
260            let gpu_stats = self.coordinator.gpu_stats();
261
262            ui.label(format!("Events: {}", stats.total_events));
263            ui.separator();
264            ui.label(format!("TPS: {}", kpis.format_throughput()));
265            ui.separator();
266
267            // GPU status with color indicator - now uses actual execution stats
268            let (gpu_text, gpu_color) = if gpu_stats.is_gpu_active() {
269                let launches = gpu_stats.total_launches();
270                if launches > 0 {
271                    (
272                        format!("CUDA ({} kernels)", launches),
273                        Color32::from_rgb(0, 200, 100),
274                    )
275                } else {
276                    ("CUDA (ready)".to_string(), Color32::from_rgb(0, 200, 100))
277                }
278            } else {
279                match self.coordinator.dfg_gpu_status() {
280                    GpuStatus::CudaReady => ("CUDA (compiling)".to_string(), Color32::YELLOW),
281                    GpuStatus::CudaPending => ("CUDA (init)".to_string(), Color32::YELLOW),
282                    GpuStatus::CudaError => ("CUDA (err)".to_string(), Color32::RED),
283                    GpuStatus::CpuFallback => {
284                        ("CPU fallback".to_string(), Color32::from_rgb(100, 150, 200))
285                    }
286                    GpuStatus::CudaNotCompiled => (
287                        "CPU (build with --features cuda)".to_string(),
288                        Color32::from_rgb(180, 180, 180),
289                    ),
290                }
291            };
292            ui.colored_label(gpu_color, format!("GPU: {}", gpu_text));
293
294            // Show GPU throughput if active
295            if gpu_stats.is_gpu_active() && gpu_stats.total_launches() > 0 {
296                let throughput = gpu_stats.throughput();
297                if throughput > 0.0 {
298                    ui.label(format!("| {:.0} elem/s", throughput));
299                }
300            }
301            ui.separator();
302
303            ui.label(format!(
304                "Patterns: {}",
305                self.coordinator
306                    .analytics()
307                    .pattern_aggregator
308                    .total_detected
309            ));
310
311            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
312                ui.label(format!("Sector: {}", self.fabric_panel.sector.name()));
313            });
314        });
315    }
316
317    /// Render main canvas.
318    fn render_canvas(&mut self, ui: &mut egui::Ui) {
319        let rect = ui.available_rect_before_wrap();
320
321        // Background
322        ui.painter()
323            .rect_filled(rect, egui::Rounding::ZERO, self.theme.background);
324
325        match self.view_mode {
326            ViewMode::Dfg => {
327                self.dfg_canvas.render(ui, &self.theme, &self.dfg, rect);
328            }
329            ViewMode::Timeline => {
330                self.timeline_canvas
331                    .render(ui, &self.theme, &self.partial_orders, rect);
332            }
333            ViewMode::Variations => {
334                self.render_variations(ui, rect);
335            }
336            ViewMode::Split => {
337                let mid = rect.center().x;
338                let left_rect = Rect::from_min_max(rect.min, Pos2::new(mid - 2.0, rect.max.y));
339                let right_rect = Rect::from_min_max(Pos2::new(mid + 2.0, rect.min.y), rect.max);
340
341                self.dfg_canvas
342                    .render(ui, &self.theme, &self.dfg, left_rect);
343
344                // Divider
345                ui.painter().line_segment(
346                    [Pos2::new(mid, rect.top()), Pos2::new(mid, rect.bottom())],
347                    egui::Stroke::new(2.0, Color32::from_rgb(38, 38, 48)),
348                );
349
350                self.timeline_canvas
351                    .render(ui, &self.theme, &self.partial_orders, right_rect);
352            }
353        }
354    }
355
356    /// Reset all views (called when sector changes).
357    fn reset_views(&mut self) {
358        // Reset DFG
359        self.dfg = DFGGraph::default();
360        self.partial_orders.clear();
361
362        // Reset canvases
363        self.dfg_canvas.reset();
364        self.timeline_canvas.reset();
365
366        // Update activity names for new sector
367        let sector = self.coordinator.pipeline().sector();
368        let names: HashMap<u32, String> = sector
369            .activities()
370            .iter()
371            .enumerate()
372            .map(|(i, a)| ((i + 1) as u32, a.name.to_string()))
373            .collect();
374        self.dfg_canvas.set_activity_names(names.clone());
375        self.timeline_canvas.set_activity_names(names);
376
377        // Reset conformance panel
378        self.conformance_panel = ConformancePanel::default();
379    }
380
381    /// Render process variations view with graph and bar chart.
382    fn render_variations(&self, ui: &mut egui::Ui, rect: Rect) {
383        let painter = ui.painter_at(rect);
384
385        // Get variations sorted by count
386        let mut variations: Vec<_> = self.coordinator.variations().values().collect();
387        variations.sort_by(|a, b| b.count.cmp(&a.count));
388
389        // Get activity names and colors
390        let sector = self.coordinator.pipeline().sector();
391        let activity_names: HashMap<u32, String> = sector
392            .activities()
393            .iter()
394            .enumerate()
395            .map(|(i, a)| ((i + 1) as u32, a.name.to_string()))
396            .collect();
397
398        let activity_colors = self.get_activity_colors(&activity_names);
399
400        let total_cases: u64 = variations.iter().map(|v| v.count).sum();
401
402        // Show "no data" message if empty
403        if variations.is_empty() {
404            painter.text(
405                rect.center(),
406                egui::Align2::CENTER_CENTER,
407                "No process variations yet.\nStart the pipeline to collect data.",
408                egui::FontId::proportional(14.0),
409                self.theme.text_muted,
410            );
411            return;
412        }
413
414        // Split view: top area for variant graph, bottom for bar chart
415        let graph_height = (rect.height() * 0.45).min(250.0);
416        let graph_rect = Rect::from_min_size(rect.min, egui::Vec2::new(rect.width(), graph_height));
417        let chart_rect = Rect::from_min_max(
418            Pos2::new(rect.left(), rect.top() + graph_height + 10.0),
419            rect.max,
420        );
421
422        // Draw section divider
423        painter.line_segment(
424            [
425                Pos2::new(rect.left() + 20.0, graph_rect.bottom() + 5.0),
426                Pos2::new(rect.right() - 20.0, graph_rect.bottom() + 5.0),
427            ],
428            egui::Stroke::new(1.0, self.theme.panel_bg.linear_multiply(1.5)),
429        );
430
431        // === TOP: Process Variant Graph ===
432        self.render_variant_graph(
433            &painter,
434            graph_rect,
435            &variations,
436            &activity_names,
437            &activity_colors,
438            total_cases,
439        );
440
441        // === BOTTOM: Bar Chart ===
442        self.render_variant_bar_chart(
443            &painter,
444            chart_rect,
445            &variations,
446            &activity_names,
447            total_cases,
448        );
449    }
450
451    /// Get consistent colors for activities.
452    fn get_activity_colors(&self, activity_names: &HashMap<u32, String>) -> HashMap<u32, Color32> {
453        let palette = [
454            Color32::from_rgb(59, 130, 246), // Blue
455            Color32::from_rgb(34, 197, 94),  // Green
456            Color32::from_rgb(239, 68, 68),  // Red
457            Color32::from_rgb(168, 85, 247), // Purple
458            Color32::from_rgb(234, 179, 8),  // Yellow
459            Color32::from_rgb(236, 72, 153), // Pink
460            Color32::from_rgb(20, 184, 166), // Teal
461            Color32::from_rgb(249, 115, 22), // Orange
462        ];
463
464        activity_names
465            .keys()
466            .enumerate()
467            .map(|(i, &id)| (id, palette[i % palette.len()]))
468            .collect()
469    }
470
471    /// Render process variant graph (like Celonis/ProM style).
472    fn render_variant_graph(
473        &self,
474        painter: &egui::Painter,
475        rect: Rect,
476        variations: &[&crate::actors::ProcessVariation],
477        activity_names: &HashMap<u32, String>,
478        activity_colors: &HashMap<u32, Color32>,
479        total_cases: u64,
480    ) {
481        // Title
482        painter.text(
483            Pos2::new(rect.left() + 20.0, rect.top() + 15.0),
484            egui::Align2::LEFT_CENTER,
485            "Process Variant Graph",
486            egui::FontId::proportional(14.0),
487            self.theme.text,
488        );
489
490        painter.text(
491            Pos2::new(rect.right() - 20.0, rect.top() + 15.0),
492            egui::Align2::RIGHT_CENTER,
493            format!("{} variants, {} cases", variations.len(), total_cases),
494            egui::FontId::proportional(10.0),
495            self.theme.text_muted,
496        );
497
498        // Calculate node positions - show top 5 variants as horizontal flows
499        let graph_y_start = rect.top() + 35.0;
500        let graph_height = rect.height() - 45.0;
501        let row_height = (graph_height / 5.0).min(40.0);
502        let node_radius = 14.0;
503        let margin_x = 80.0;
504        let graph_width = rect.width() - margin_x * 2.0;
505
506        for (var_idx, var) in variations.iter().take(5).enumerate() {
507            let y = graph_y_start + var_idx as f32 * row_height + row_height / 2.0;
508            let num_activities = var.activities.len().max(1);
509            let spacing = graph_width / (num_activities + 1) as f32;
510
511            let percentage = if total_cases > 0 {
512                var.count as f32 / total_cases as f32
513            } else {
514                0.0
515            };
516
517            // Draw variant label
518            painter.text(
519                Pos2::new(rect.left() + 10.0, y),
520                egui::Align2::LEFT_CENTER,
521                format!("#{} ({:.0}%)", var_idx + 1, percentage * 100.0),
522                egui::FontId::proportional(9.0),
523                self.theme.text_muted,
524            );
525
526            // Draw edges first (so nodes are on top)
527            for i in 0..var.activities.len().saturating_sub(1) {
528                let x1 = rect.left() + margin_x + (i + 1) as f32 * spacing + node_radius;
529                let x2 = rect.left() + margin_x + (i + 2) as f32 * spacing - node_radius;
530
531                // Edge thickness based on variant frequency
532                let edge_width = (1.0 + percentage * 4.0).min(3.0);
533
534                painter.line_segment(
535                    [Pos2::new(x1, y), Pos2::new(x2, y)],
536                    egui::Stroke::new(edge_width, self.theme.accent.linear_multiply(0.6)),
537                );
538
539                // Arrow head
540                let arrow_size = 5.0;
541                painter.line_segment(
542                    [Pos2::new(x2 - arrow_size, y - arrow_size), Pos2::new(x2, y)],
543                    egui::Stroke::new(edge_width, self.theme.accent.linear_multiply(0.6)),
544                );
545                painter.line_segment(
546                    [Pos2::new(x2 - arrow_size, y + arrow_size), Pos2::new(x2, y)],
547                    egui::Stroke::new(edge_width, self.theme.accent.linear_multiply(0.6)),
548                );
549            }
550
551            // Draw activity nodes
552            for (i, &activity_id) in var.activities.iter().enumerate() {
553                let x = rect.left() + margin_x + (i + 1) as f32 * spacing;
554                let color = activity_colors
555                    .get(&activity_id)
556                    .copied()
557                    .unwrap_or(self.theme.accent);
558
559                // Node circle
560                painter.circle_filled(Pos2::new(x, y), node_radius, color);
561
562                // Conformant variant indicator (border)
563                if !var.is_conformant {
564                    painter.circle_stroke(
565                        Pos2::new(x, y),
566                        node_radius + 2.0,
567                        egui::Stroke::new(2.0, self.theme.error),
568                    );
569                }
570
571                // Activity abbreviation
572                let name = activity_names
573                    .get(&activity_id)
574                    .map(|n| n.chars().take(2).collect::<String>())
575                    .unwrap_or_else(|| format!("{}", activity_id));
576
577                painter.text(
578                    Pos2::new(x, y),
579                    egui::Align2::CENTER_CENTER,
580                    name,
581                    egui::FontId::proportional(8.0),
582                    Color32::WHITE,
583                );
584            }
585
586            // Conformance indicator at end
587            let conf_x = rect.right() - 25.0;
588            let (conf_symbol, conf_color) = if var.is_conformant {
589                ("✓", self.theme.success)
590            } else {
591                ("✗", self.theme.error)
592            };
593            painter.text(
594                Pos2::new(conf_x, y),
595                egui::Align2::CENTER_CENTER,
596                conf_symbol,
597                egui::FontId::proportional(12.0),
598                conf_color,
599            );
600        }
601    }
602
603    /// Render variant bar chart (Pareto-style).
604    fn render_variant_bar_chart(
605        &self,
606        painter: &egui::Painter,
607        rect: Rect,
608        variations: &[&crate::actors::ProcessVariation],
609        activity_names: &HashMap<u32, String>,
610        total_cases: u64,
611    ) {
612        // Title
613        painter.text(
614            Pos2::new(rect.left() + 20.0, rect.top() + 10.0),
615            egui::Align2::LEFT_CENTER,
616            "Variant Distribution",
617            egui::FontId::proportional(14.0),
618            self.theme.text,
619        );
620
621        let chart_y_start = rect.top() + 30.0;
622        let chart_height = rect.height() - 40.0;
623        let row_height = (chart_height / 12.0).min(28.0);
624        let bar_x_start = rect.left() + 160.0;
625        let max_bar_width = rect.width() - 240.0;
626
627        // Cumulative percentage for Pareto line
628        let mut cumulative = 0.0;
629
630        for (i, var) in variations.iter().take(12).enumerate() {
631            let y = chart_y_start + i as f32 * row_height + row_height / 2.0;
632
633            let percentage = if total_cases > 0 {
634                var.count as f32 / total_cases as f32
635            } else {
636                0.0
637            };
638            cumulative += percentage;
639
640            // Rank
641            painter.text(
642                Pos2::new(rect.left() + 15.0, y),
643                egui::Align2::LEFT_CENTER,
644                format!("#{}", i + 1),
645                egui::FontId::proportional(10.0),
646                self.theme.text_muted,
647            );
648
649            // Count
650            painter.text(
651                Pos2::new(rect.left() + 45.0, y),
652                egui::Align2::LEFT_CENTER,
653                format!("{}", var.count),
654                egui::FontId::proportional(10.0),
655                self.theme.text,
656            );
657
658            // Activity sequence preview
659            let activity_seq: String = var
660                .activities
661                .iter()
662                .take(4)
663                .map(|id| {
664                    activity_names
665                        .get(id)
666                        .map(|n| n.chars().take(2).collect::<String>())
667                        .unwrap_or_else(|| "?".to_string())
668                })
669                .collect::<Vec<_>>()
670                .join("→");
671
672            let seq_display = if var.activities.len() > 4 {
673                format!("{}…", activity_seq)
674            } else {
675                activity_seq
676            };
677
678            painter.text(
679                Pos2::new(rect.left() + 85.0, y),
680                egui::Align2::LEFT_CENTER,
681                seq_display,
682                egui::FontId::proportional(8.0),
683                self.theme.text_muted,
684            );
685
686            // Bar
687            let bar_width = max_bar_width * percentage;
688            let bar_rect = Rect::from_min_size(
689                Pos2::new(bar_x_start, y - row_height * 0.35),
690                egui::Vec2::new(bar_width.max(2.0), row_height * 0.7),
691            );
692
693            let bar_color = if var.is_conformant {
694                self.theme.success.linear_multiply(0.7)
695            } else {
696                self.theme.error.linear_multiply(0.7)
697            };
698            painter.rect_filled(bar_rect, egui::Rounding::same(3.0), bar_color);
699
700            // Percentage label
701            painter.text(
702                Pos2::new(bar_x_start + bar_width + 5.0, y),
703                egui::Align2::LEFT_CENTER,
704                format!("{:.1}%", percentage * 100.0),
705                egui::FontId::proportional(9.0),
706                self.theme.text,
707            );
708
709            // Cumulative percentage (Pareto)
710            painter.text(
711                Pos2::new(rect.right() - 40.0, y),
712                egui::Align2::LEFT_CENTER,
713                format!("Σ{:.0}%", cumulative * 100.0),
714                egui::FontId::proportional(8.0),
715                self.theme.text_muted,
716            );
717
718            // Duration
719            painter.text(
720                Pos2::new(rect.right() - 10.0, y),
721                egui::Align2::RIGHT_CENTER,
722                format!("{:.0}ms", var.avg_duration_ms),
723                egui::FontId::proportional(8.0),
724                self.theme.text_muted,
725            );
726        }
727
728        // Show "more variants" indicator if needed
729        if variations.len() > 12 {
730            let y = chart_y_start + 12.0 * row_height + 5.0;
731            painter.text(
732                Pos2::new(rect.center().x, y),
733                egui::Align2::CENTER_TOP,
734                format!("... and {} more variants", variations.len() - 12),
735                egui::FontId::proportional(10.0),
736                self.theme.text_muted,
737            );
738        }
739    }
740}