Skip to main content

ringkernel_accnet/gui/
charts.rs

1//! Chart and visualization widgets for the analytics dashboard.
2//!
3//! Provides reusable chart components:
4//! - Histogram for Benford's Law analysis
5//! - Bar charts for pattern breakdowns
6//! - Donut charts for distributions
7//! - Enhanced sparklines with tooltips
8
9use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Vec2};
10use std::f32::consts::PI;
11
12use super::theme::AccNetTheme;
13
14/// Benford's Law expected distribution for digits 1-9.
15pub const BENFORD_EXPECTED: [f64; 9] = [
16    0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046,
17];
18
19/// Histogram chart widget.
20pub struct Histogram {
21    /// Data values (counts or frequencies).
22    pub values: Vec<f64>,
23    /// Labels for each bar.
24    pub labels: Vec<String>,
25    /// Expected values (optional, for comparison overlay).
26    pub expected: Option<Vec<f64>>,
27    /// Bar color.
28    pub bar_color: Color32,
29    /// Expected overlay color.
30    pub expected_color: Color32,
31    /// Title.
32    pub title: String,
33    /// Height of the chart.
34    pub height: f32,
35}
36
37impl Histogram {
38    /// Create a new histogram.
39    pub fn new(title: impl Into<String>) -> Self {
40        Self {
41            values: Vec::new(),
42            labels: Vec::new(),
43            expected: None,
44            bar_color: Color32::from_rgb(100, 150, 230),
45            expected_color: Color32::from_rgb(255, 180, 100),
46            title: title.into(),
47            height: 120.0,
48        }
49    }
50
51    /// Create a Benford's Law histogram.
52    pub fn benford(actual_counts: [usize; 9]) -> Self {
53        let total: usize = actual_counts.iter().sum();
54        let total_f = total.max(1) as f64;
55
56        let values: Vec<f64> = actual_counts.iter().map(|&c| c as f64 / total_f).collect();
57        let labels: Vec<String> = (1..=9).map(|d| d.to_string()).collect();
58        let expected = Some(BENFORD_EXPECTED.to_vec());
59
60        Self {
61            values,
62            labels,
63            expected,
64            bar_color: Color32::from_rgb(100, 180, 130),
65            expected_color: Color32::from_rgb(255, 100, 100),
66            title: "Benford's Law Distribution".to_string(),
67            height: 100.0,
68        }
69    }
70
71    /// Set the height.
72    pub fn height(mut self, height: f32) -> Self {
73        self.height = height;
74        self
75    }
76
77    /// Render the histogram.
78    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
79        let width = ui.available_width();
80        let (response, painter) =
81            ui.allocate_painter(Vec2::new(width, self.height + 30.0), Sense::hover());
82        let rect = response.rect;
83
84        // Title
85        painter.text(
86            Pos2::new(rect.left() + 5.0, rect.top()),
87            egui::Align2::LEFT_TOP,
88            &self.title,
89            egui::FontId::proportional(11.0),
90            theme.text_secondary,
91        );
92
93        // Chart area
94        let chart_rect = Rect::from_min_max(
95            Pos2::new(rect.left() + 20.0, rect.top() + 18.0),
96            Pos2::new(rect.right() - 5.0, rect.bottom() - 15.0),
97        );
98
99        // Background
100        painter.rect_filled(chart_rect, 2.0, Color32::from_rgb(25, 25, 35));
101
102        if self.values.is_empty() {
103            painter.text(
104                chart_rect.center(),
105                egui::Align2::CENTER_CENTER,
106                "No data",
107                egui::FontId::proportional(10.0),
108                theme.text_secondary,
109            );
110            return response;
111        }
112
113        let n = self.values.len();
114        let bar_width = (chart_rect.width() - 10.0) / n as f32;
115        let gap = bar_width * 0.15;
116        let max_val = self
117            .values
118            .iter()
119            .copied()
120            .fold(0.0_f64, f64::max)
121            .max(
122                self.expected
123                    .as_ref()
124                    .map(|e| e.iter().copied().fold(0.0_f64, f64::max))
125                    .unwrap_or(0.0),
126            )
127            .max(0.01);
128
129        // Draw bars
130        for (i, &val) in self.values.iter().enumerate() {
131            let x = chart_rect.left() + 5.0 + i as f32 * bar_width;
132            let bar_height = (val / max_val) as f32 * (chart_rect.height() - 5.0);
133
134            let bar_rect = Rect::from_min_max(
135                Pos2::new(x + gap / 2.0, chart_rect.bottom() - bar_height),
136                Pos2::new(x + bar_width - gap / 2.0, chart_rect.bottom()),
137            );
138
139            painter.rect_filled(bar_rect, 2.0, self.bar_color);
140
141            // Expected value marker (horizontal line)
142            if let Some(ref expected) = self.expected {
143                if i < expected.len() {
144                    let expected_y = chart_rect.bottom()
145                        - (expected[i] / max_val) as f32 * (chart_rect.height() - 5.0);
146                    painter.line_segment(
147                        [
148                            Pos2::new(x + gap / 2.0, expected_y),
149                            Pos2::new(x + bar_width - gap / 2.0, expected_y),
150                        ],
151                        Stroke::new(2.0, self.expected_color),
152                    );
153                }
154            }
155
156            // Label
157            if i < self.labels.len() {
158                painter.text(
159                    Pos2::new(x + bar_width / 2.0, chart_rect.bottom() + 2.0),
160                    egui::Align2::CENTER_TOP,
161                    &self.labels[i],
162                    egui::FontId::proportional(9.0),
163                    theme.text_secondary,
164                );
165            }
166        }
167
168        // Y-axis scale
169        painter.text(
170            Pos2::new(chart_rect.left() - 2.0, chart_rect.top()),
171            egui::Align2::RIGHT_TOP,
172            format!("{:.0}%", max_val * 100.0),
173            egui::FontId::proportional(8.0),
174            theme.text_secondary,
175        );
176
177        response
178    }
179}
180
181/// Horizontal bar chart for category breakdowns.
182pub struct BarChart {
183    /// Category names and values.
184    pub data: Vec<(String, f64, Color32)>,
185    /// Title.
186    pub title: String,
187    /// Height per bar.
188    pub bar_height: f32,
189}
190
191impl BarChart {
192    /// Create a new bar chart.
193    pub fn new(title: impl Into<String>) -> Self {
194        Self {
195            data: Vec::new(),
196            title: title.into(),
197            bar_height: 18.0,
198        }
199    }
200
201    /// Add a data point.
202    pub fn add(mut self, label: impl Into<String>, value: f64, color: Color32) -> Self {
203        self.data.push((label.into(), value, color));
204        self
205    }
206
207    /// Render the bar chart.
208    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
209        let width = ui.available_width();
210        let total_height = 18.0 + self.data.len() as f32 * (self.bar_height + 4.0);
211
212        let (response, painter) =
213            ui.allocate_painter(Vec2::new(width, total_height), Sense::hover());
214        let rect = response.rect;
215
216        // Title
217        painter.text(
218            Pos2::new(rect.left() + 5.0, rect.top()),
219            egui::Align2::LEFT_TOP,
220            &self.title,
221            egui::FontId::proportional(11.0),
222            theme.text_secondary,
223        );
224
225        if self.data.is_empty() {
226            return response;
227        }
228
229        let max_val = self
230            .data
231            .iter()
232            .map(|(_, v, _)| *v)
233            .fold(0.0_f64, f64::max)
234            .max(1.0);
235        let label_width = 80.0;
236        let bar_area_width = width - label_width - 45.0;
237
238        for (i, (label, value, color)) in self.data.iter().enumerate() {
239            let y = rect.top() + 18.0 + i as f32 * (self.bar_height + 4.0);
240
241            // Label
242            painter.text(
243                Pos2::new(rect.left() + label_width - 5.0, y + self.bar_height / 2.0),
244                egui::Align2::RIGHT_CENTER,
245                label,
246                egui::FontId::proportional(10.0),
247                theme.text_primary,
248            );
249
250            // Bar
251            let bar_width = ((*value / max_val) as f32 * bar_area_width).max(2.0);
252            let bar_rect = Rect::from_min_size(
253                Pos2::new(rect.left() + label_width, y),
254                Vec2::new(bar_width, self.bar_height),
255            );
256            painter.rect_filled(bar_rect, 2.0, *color);
257
258            // Value
259            painter.text(
260                Pos2::new(
261                    rect.left() + label_width + bar_width + 5.0,
262                    y + self.bar_height / 2.0,
263                ),
264                egui::Align2::LEFT_CENTER,
265                format!("{}", *value as usize),
266                egui::FontId::proportional(10.0),
267                theme.text_secondary,
268            );
269        }
270
271        response
272    }
273}
274
275/// Donut/pie chart for distributions.
276pub struct DonutChart {
277    /// Segments: (label, value, color).
278    pub segments: Vec<(String, f64, Color32)>,
279    /// Title.
280    pub title: String,
281    /// Outer radius.
282    pub radius: f32,
283    /// Inner radius (hole).
284    pub inner_radius: f32,
285}
286
287impl DonutChart {
288    /// Create a new donut chart.
289    pub fn new(title: impl Into<String>) -> Self {
290        Self {
291            segments: Vec::new(),
292            title: title.into(),
293            radius: 45.0,
294            inner_radius: 25.0,
295        }
296    }
297
298    /// Add a segment.
299    pub fn add(mut self, label: impl Into<String>, value: f64, color: Color32) -> Self {
300        self.segments.push((label.into(), value, color));
301        self
302    }
303
304    /// Render the donut chart.
305    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
306        let width = ui.available_width();
307        let height = self.radius * 2.0 + 50.0;
308
309        let (response, painter) = ui.allocate_painter(Vec2::new(width, height), Sense::hover());
310        let rect = response.rect;
311
312        // Title
313        painter.text(
314            Pos2::new(rect.left() + 5.0, rect.top()),
315            egui::Align2::LEFT_TOP,
316            &self.title,
317            egui::FontId::proportional(11.0),
318            theme.text_secondary,
319        );
320
321        if self.segments.is_empty() {
322            return response;
323        }
324
325        let total: f64 = self.segments.iter().map(|(_, v, _)| *v).sum();
326        if total == 0.0 {
327            return response;
328        }
329
330        let center = Pos2::new(
331            rect.left() + self.radius + 10.0,
332            rect.top() + self.radius + 18.0,
333        );
334        let mut start_angle = -PI / 2.0; // Start at top
335
336        // Draw segments using triangles for proper rendering
337        for (_label, value, color) in &self.segments {
338            let sweep_angle = (*value / total) as f32 * 2.0 * PI;
339
340            if sweep_angle > 0.01 {
341                // Draw arc using small triangular segments
342                let steps = (sweep_angle * 30.0).max(8.0) as usize;
343
344                for i in 0..steps {
345                    let angle1 = start_angle + sweep_angle * (i as f32 / steps as f32);
346                    let angle2 = start_angle + sweep_angle * ((i + 1) as f32 / steps as f32);
347
348                    // Outer triangle
349                    let outer1 = Pos2::new(
350                        center.x + self.radius * angle1.cos(),
351                        center.y + self.radius * angle1.sin(),
352                    );
353                    let outer2 = Pos2::new(
354                        center.x + self.radius * angle2.cos(),
355                        center.y + self.radius * angle2.sin(),
356                    );
357                    let inner1 = Pos2::new(
358                        center.x + self.inner_radius * angle1.cos(),
359                        center.y + self.inner_radius * angle1.sin(),
360                    );
361                    let inner2 = Pos2::new(
362                        center.x + self.inner_radius * angle2.cos(),
363                        center.y + self.inner_radius * angle2.sin(),
364                    );
365
366                    // Two triangles to form a quad
367                    painter.add(egui::Shape::convex_polygon(
368                        vec![outer1, outer2, inner2, inner1],
369                        *color,
370                        Stroke::NONE,
371                    ));
372                }
373
374                // Draw segment border
375                let end_angle = start_angle + sweep_angle;
376                painter.line_segment(
377                    [
378                        Pos2::new(
379                            center.x + self.inner_radius * start_angle.cos(),
380                            center.y + self.inner_radius * start_angle.sin(),
381                        ),
382                        Pos2::new(
383                            center.x + self.radius * start_angle.cos(),
384                            center.y + self.radius * start_angle.sin(),
385                        ),
386                    ],
387                    Stroke::new(1.0, Color32::from_rgb(50, 50, 60)),
388                );
389                painter.line_segment(
390                    [
391                        Pos2::new(
392                            center.x + self.inner_radius * end_angle.cos(),
393                            center.y + self.inner_radius * end_angle.sin(),
394                        ),
395                        Pos2::new(
396                            center.x + self.radius * end_angle.cos(),
397                            center.y + self.radius * end_angle.sin(),
398                        ),
399                    ],
400                    Stroke::new(1.0, Color32::from_rgb(50, 50, 60)),
401                );
402            }
403
404            start_angle += sweep_angle;
405        }
406
407        // Center text (total)
408        painter.text(
409            center,
410            egui::Align2::CENTER_CENTER,
411            format!("{}", total as usize),
412            egui::FontId::proportional(12.0),
413            theme.text_primary,
414        );
415
416        // Legend (to the right)
417        let legend_x = center.x + self.radius + 20.0;
418        let legend_y_start = rect.top() + 18.0;
419
420        for (i, (label, value, color)) in self.segments.iter().enumerate() {
421            let y = legend_y_start + i as f32 * 14.0;
422
423            // Color dot
424            painter.circle_filled(Pos2::new(legend_x, y + 5.0), 4.0, *color);
425
426            // Label and value
427            let pct = if total > 0.0 {
428                (*value / total * 100.0) as usize
429            } else {
430                0
431            };
432            painter.text(
433                Pos2::new(legend_x + 10.0, y),
434                egui::Align2::LEFT_TOP,
435                format!("{} ({}%)", label, pct),
436                egui::FontId::proportional(9.0),
437                theme.text_secondary,
438            );
439        }
440
441        response
442    }
443}
444
445/// Sparkline with area fill and gradient.
446pub struct Sparkline {
447    /// Data points.
448    pub values: Vec<f32>,
449    /// Max points to keep.
450    pub max_points: usize,
451    /// Line color.
452    pub color: Color32,
453    /// Title.
454    pub title: String,
455    /// Height.
456    pub height: f32,
457    /// Show min/max labels.
458    pub show_labels: bool,
459}
460
461impl Sparkline {
462    /// Create a new sparkline.
463    pub fn new(title: impl Into<String>) -> Self {
464        Self {
465            values: Vec::new(),
466            max_points: 100,
467            color: Color32::from_rgb(100, 200, 150),
468            title: title.into(),
469            height: 50.0,
470            show_labels: true,
471        }
472    }
473
474    /// Add a value.
475    pub fn push(&mut self, value: f32) {
476        self.values.push(value);
477        if self.values.len() > self.max_points {
478            self.values.remove(0);
479        }
480    }
481
482    /// Set color.
483    pub fn color(mut self, color: Color32) -> Self {
484        self.color = color;
485        self
486    }
487
488    /// Render the sparkline.
489    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
490        let width = ui.available_width();
491        let (response, painter) =
492            ui.allocate_painter(Vec2::new(width, self.height + 16.0), Sense::hover());
493        let rect = response.rect;
494
495        // Title
496        painter.text(
497            Pos2::new(rect.left() + 5.0, rect.top()),
498            egui::Align2::LEFT_TOP,
499            &self.title,
500            egui::FontId::proportional(11.0),
501            theme.text_secondary,
502        );
503
504        // Chart area
505        let chart_rect = Rect::from_min_max(
506            Pos2::new(rect.left() + 5.0, rect.top() + 14.0),
507            Pos2::new(rect.right() - 5.0, rect.bottom()),
508        );
509
510        // Background
511        painter.rect_filled(chart_rect, 2.0, Color32::from_rgb(25, 25, 35));
512
513        if self.values.len() < 2 {
514            return response;
515        }
516
517        let min_val = self.values.iter().copied().fold(f32::INFINITY, f32::min);
518        let max_val = self
519            .values
520            .iter()
521            .copied()
522            .fold(f32::NEG_INFINITY, f32::max);
523        let range = (max_val - min_val).max(0.01);
524
525        let n = self.values.len();
526        let points: Vec<Pos2> = self
527            .values
528            .iter()
529            .enumerate()
530            .map(|(i, &val)| {
531                let x = chart_rect.left() + (i as f32 / (n - 1) as f32) * chart_rect.width();
532                let y = chart_rect.bottom()
533                    - ((val - min_val) / range) * (chart_rect.height() - 4.0)
534                    - 2.0;
535                Pos2::new(x, y)
536            })
537            .collect();
538
539        // Area fill
540        let mut area_points = points.clone();
541        area_points.push(Pos2::new(chart_rect.right(), chart_rect.bottom()));
542        area_points.push(Pos2::new(chart_rect.left(), chart_rect.bottom()));
543
544        let fill_color =
545            Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), 40);
546        painter.add(egui::Shape::convex_polygon(
547            area_points,
548            fill_color,
549            Stroke::NONE,
550        ));
551
552        // Line
553        for i in 0..(points.len() - 1) {
554            painter.line_segment([points[i], points[i + 1]], Stroke::new(2.0, self.color));
555        }
556
557        // Current value dot
558        if let Some(last) = points.last() {
559            painter.circle_filled(*last, 3.0, self.color);
560        }
561
562        // Labels
563        if self.show_labels && !self.values.is_empty() {
564            let current = self.values.last().unwrap_or(&0.0);
565            painter.text(
566                Pos2::new(chart_rect.right() - 3.0, chart_rect.top() + 3.0),
567                egui::Align2::RIGHT_TOP,
568                format!("{:.1}", current),
569                egui::FontId::proportional(9.0),
570                self.color,
571            );
572        }
573
574        response
575    }
576}
577
578/// Method distribution chart for A-E transformation methods.
579pub struct MethodDistribution {
580    /// Counts for each method.
581    pub counts: [usize; 5],
582    /// Whether to show explanation text.
583    pub show_explanation: bool,
584}
585
586impl MethodDistribution {
587    /// Create from counts.
588    pub fn new(counts: [usize; 5]) -> Self {
589        Self {
590            counts,
591            show_explanation: true,
592        }
593    }
594
595    /// Set whether to show explanation.
596    pub fn with_explanation(mut self, show: bool) -> Self {
597        self.show_explanation = show;
598        self
599    }
600
601    /// Render the distribution.
602    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
603        let labels = ["A", "B", "C", "D", "E"];
604        let descriptions = [
605            "1:1 direct mapping", // A
606            "n:n amount match",   // B
607            "n:m partition",      // C
608            "Higher aggregate",   // D
609            "Decomposition",      // E
610        ];
611        let colors = [
612            Color32::from_rgb(100, 180, 230), // A - blue
613            Color32::from_rgb(150, 200, 130), // B - green
614            Color32::from_rgb(230, 180, 100), // C - orange
615            Color32::from_rgb(200, 130, 180), // D - purple
616            Color32::from_rgb(180, 130, 130), // E - red
617        ];
618
619        let total: usize = self.counts.iter().sum();
620        let width = ui.available_width();
621        let base_height = if self.show_explanation { 95.0 } else { 60.0 };
622
623        let (response, painter) =
624            ui.allocate_painter(Vec2::new(width, base_height), Sense::hover());
625        let rect = response.rect;
626
627        // Title
628        painter.text(
629            Pos2::new(rect.left() + 5.0, rect.top()),
630            egui::Align2::LEFT_TOP,
631            "Transformation Methods",
632            egui::FontId::proportional(11.0),
633            theme.text_secondary,
634        );
635
636        let bar_y = rect.top() + 18.0;
637        let bar_height = 20.0;
638        let bar_width = width - 10.0;
639
640        // Background
641        painter.rect_filled(
642            Rect::from_min_size(
643                Pos2::new(rect.left() + 5.0, bar_y),
644                Vec2::new(bar_width, bar_height),
645            ),
646            4.0,
647            Color32::from_rgb(40, 40, 50),
648        );
649
650        if total == 0 {
651            return response;
652        }
653
654        // Stacked bar
655        let mut x = rect.left() + 5.0;
656        for (i, &count) in self.counts.iter().enumerate() {
657            let segment_width = (count as f32 / total as f32) * bar_width;
658            if segment_width > 1.0 {
659                painter.rect_filled(
660                    Rect::from_min_size(Pos2::new(x, bar_y), Vec2::new(segment_width, bar_height)),
661                    if i == 0 || i == 4 { 4.0 } else { 0.0 },
662                    colors[i],
663                );
664                x += segment_width;
665            }
666        }
667
668        // Legend below
669        let legend_y = bar_y + bar_height + 5.0;
670        let legend_spacing = bar_width / 5.0;
671
672        for (i, label) in labels.iter().enumerate() {
673            let x = rect.left() + 5.0 + legend_spacing * (i as f32 + 0.5);
674            let pct = (self.counts[i] * 100).checked_div(total).unwrap_or(0);
675
676            painter.circle_filled(Pos2::new(x - 15.0, legend_y + 5.0), 4.0, colors[i]);
677            painter.text(
678                Pos2::new(x - 8.0, legend_y),
679                egui::Align2::LEFT_TOP,
680                format!("{}: {}%", label, pct),
681                egui::FontId::proportional(9.0),
682                theme.text_secondary,
683            );
684        }
685
686        // Explanation text below legend
687        if self.show_explanation {
688            let exp_y = legend_y + 18.0;
689            let exp_spacing = bar_width / 5.0;
690
691            for (i, desc) in descriptions.iter().enumerate() {
692                let x = rect.left() + 5.0 + exp_spacing * (i as f32 + 0.5);
693                painter.text(
694                    Pos2::new(x, exp_y),
695                    egui::Align2::CENTER_TOP,
696                    *desc,
697                    egui::FontId::proportional(7.5),
698                    Color32::from_rgb(120, 120, 135),
699                );
700            }
701        }
702
703        response
704    }
705}
706
707/// Horizontal bar chart for account balances (in thousands/millions).
708pub struct BalanceBarChart {
709    /// Category names and values.
710    pub data: Vec<(String, f64, Color32)>,
711    /// Title.
712    pub title: String,
713    /// Height per bar.
714    pub bar_height: f32,
715}
716
717impl BalanceBarChart {
718    /// Create a new balance bar chart.
719    pub fn new(title: impl Into<String>) -> Self {
720        Self {
721            data: Vec::new(),
722            title: title.into(),
723            bar_height: 14.0,
724        }
725    }
726
727    /// Add a data point (value in raw units, will be formatted).
728    pub fn add(mut self, label: impl Into<String>, value: f64, color: Color32) -> Self {
729        self.data.push((label.into(), value, color));
730        self
731    }
732
733    /// Format value with K/M suffix.
734    fn format_value(value: f64) -> String {
735        if value >= 1_000_000.0 {
736            format!("{:.1}M", value / 1_000_000.0)
737        } else if value >= 1_000.0 {
738            format!("{:.1}K", value / 1_000.0)
739        } else {
740            format!("{:.0}", value)
741        }
742    }
743
744    /// Render the balance bar chart.
745    pub fn show(&self, ui: &mut egui::Ui, theme: &AccNetTheme) -> Response {
746        let width = ui.available_width();
747        let total_height = 18.0 + self.data.len() as f32 * (self.bar_height + 3.0);
748
749        let (response, painter) =
750            ui.allocate_painter(Vec2::new(width, total_height), Sense::hover());
751        let rect = response.rect;
752
753        // Title
754        painter.text(
755            Pos2::new(rect.left() + 5.0, rect.top()),
756            egui::Align2::LEFT_TOP,
757            &self.title,
758            egui::FontId::proportional(11.0),
759            theme.text_secondary,
760        );
761
762        if self.data.is_empty() {
763            return response;
764        }
765
766        let max_val = self
767            .data
768            .iter()
769            .map(|(_, v, _)| *v)
770            .fold(0.0_f64, f64::max)
771            .max(1.0);
772        let label_width = 55.0;
773        let value_width = 40.0;
774        let bar_area_width = width - label_width - value_width - 15.0;
775
776        for (i, (label, value, color)) in self.data.iter().enumerate() {
777            let y = rect.top() + 18.0 + i as f32 * (self.bar_height + 3.0);
778
779            // Label
780            painter.text(
781                Pos2::new(rect.left() + label_width - 3.0, y + self.bar_height / 2.0),
782                egui::Align2::RIGHT_CENTER,
783                label,
784                egui::FontId::proportional(9.0),
785                theme.text_primary,
786            );
787
788            // Bar background
789            let bar_bg = Rect::from_min_size(
790                Pos2::new(rect.left() + label_width, y),
791                Vec2::new(bar_area_width, self.bar_height),
792            );
793            painter.rect_filled(bar_bg, 2.0, Color32::from_rgb(35, 35, 45));
794
795            // Bar fill
796            let bar_width = ((*value / max_val) as f32 * bar_area_width).max(2.0);
797            let bar_rect = Rect::from_min_size(
798                Pos2::new(rect.left() + label_width, y),
799                Vec2::new(bar_width, self.bar_height),
800            );
801            painter.rect_filled(bar_rect, 2.0, *color);
802
803            // Value
804            painter.text(
805                Pos2::new(
806                    rect.left() + label_width + bar_area_width + 5.0,
807                    y + self.bar_height / 2.0,
808                ),
809                egui::Align2::LEFT_CENTER,
810                Self::format_value(*value),
811                egui::FontId::proportional(9.0),
812                theme.text_secondary,
813            );
814        }
815
816        response
817    }
818}
819
820/// Live stats ticker showing animated counters.
821pub struct LiveTicker {
822    /// Label-value pairs.
823    pub items: Vec<(String, String, Color32)>,
824}
825
826impl LiveTicker {
827    /// Create a new ticker.
828    pub fn new() -> Self {
829        Self { items: Vec::new() }
830    }
831
832    /// Add an item.
833    pub fn add(
834        mut self,
835        label: impl Into<String>,
836        value: impl Into<String>,
837        color: Color32,
838    ) -> Self {
839        self.items.push((label.into(), value.into(), color));
840        self
841    }
842
843    /// Render the ticker.
844    pub fn show(&self, ui: &mut egui::Ui, _theme: &AccNetTheme) -> Response {
845        let width = ui.available_width();
846        let (response, painter) = ui.allocate_painter(Vec2::new(width, 30.0), Sense::hover());
847        let rect = response.rect;
848
849        if self.items.is_empty() {
850            return response;
851        }
852
853        let spacing = width / self.items.len() as f32;
854
855        for (i, (label, value, color)) in self.items.iter().enumerate() {
856            let x = rect.left() + spacing * (i as f32 + 0.5);
857
858            // Value (large)
859            painter.text(
860                Pos2::new(x, rect.top()),
861                egui::Align2::CENTER_TOP,
862                value,
863                egui::FontId::proportional(16.0),
864                *color,
865            );
866
867            // Label (small)
868            painter.text(
869                Pos2::new(x, rect.top() + 18.0),
870                egui::Align2::CENTER_TOP,
871                label,
872                egui::FontId::proportional(9.0),
873                Color32::from_rgb(150, 150, 160),
874            );
875        }
876
877        response
878    }
879}
880
881impl Default for LiveTicker {
882    fn default() -> Self {
883        Self::new()
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    #[test]
892    fn test_benford_histogram() {
893        let counts = [30, 18, 12, 10, 8, 7, 6, 5, 4];
894        let hist = Histogram::benford(counts);
895        assert_eq!(hist.values.len(), 9);
896        assert_eq!(hist.labels.len(), 9);
897    }
898
899    #[test]
900    fn test_sparkline() {
901        let mut spark = Sparkline::new("Test");
902        for i in 0..150 {
903            spark.push(i as f32);
904        }
905        assert_eq!(spark.values.len(), 100);
906    }
907
908    #[test]
909    fn test_method_distribution() {
910        let dist = MethodDistribution::new([100, 50, 30, 15, 5]);
911        assert_eq!(dist.counts.iter().sum::<usize>(), 200);
912    }
913}