Skip to main content

presentar_terminal/widgets/
histogram.rs

1//! Histogram widget with multiple binning strategies.
2//!
3//! Implements P202 from SPEC-024 Section 15.2.
4
5use crate::theme::Gradient;
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Binning strategy for the histogram.
14#[derive(Debug, Clone, Copy, Default)]
15pub enum BinStrategy {
16    /// Fixed number of bins.
17    Count(usize),
18    /// Fixed bin width.
19    Width(f64),
20    /// Sturges' formula: ceil(log2(n) + 1).
21    #[default]
22    Sturges,
23    /// Scott's rule: 3.49 * std / n^(1/3).
24    Scott,
25    /// Freedman-Diaconis rule: 2 * IQR / n^(1/3).
26    FreedmanDiaconis,
27}
28
29/// Bar orientation.
30#[derive(Debug, Clone, Copy, Default)]
31pub enum HistogramOrientation {
32    /// Vertical bars (default).
33    #[default]
34    Vertical,
35    /// Horizontal bars.
36    Horizontal,
37}
38
39/// Bar rendering style.
40#[derive(Debug, Clone, Copy, Default)]
41pub enum BarStyle {
42    /// Solid filled bars.
43    #[default]
44    Solid,
45    /// Block characters (▁▂▃▄▅▆▇█).
46    Blocks,
47    /// ASCII characters.
48    Ascii,
49}
50
51/// Histogram widget.
52#[derive(Debug, Clone)]
53pub struct Histogram {
54    data: Vec<f64>,
55    bins: BinStrategy,
56    orientation: HistogramOrientation,
57    bar_style: BarStyle,
58    color: Color,
59    gradient: Option<Gradient>,
60    show_labels: bool,
61    bounds: Rect,
62    /// Computed bin edges and counts.
63    computed_bins: Vec<(f64, f64, usize)>, // (start, end, count)
64}
65
66impl Histogram {
67    /// Create a new histogram from data.
68    #[must_use]
69    pub fn new(data: Vec<f64>) -> Self {
70        let mut hist = Self {
71            data,
72            bins: BinStrategy::default(),
73            orientation: HistogramOrientation::default(),
74            bar_style: BarStyle::default(),
75            color: Color::new(0.3, 0.7, 1.0, 1.0),
76            gradient: None,
77            show_labels: true,
78            bounds: Rect::default(),
79            computed_bins: Vec::new(),
80        };
81        hist.compute_bins();
82        hist
83    }
84
85    /// Set binning strategy.
86    #[must_use]
87    pub fn with_bins(mut self, strategy: BinStrategy) -> Self {
88        self.bins = strategy;
89        self.compute_bins();
90        self
91    }
92
93    /// Set orientation.
94    #[must_use]
95    pub fn with_orientation(mut self, orientation: HistogramOrientation) -> Self {
96        self.orientation = orientation;
97        self
98    }
99
100    /// Set bar style.
101    #[must_use]
102    pub fn with_bar_style(mut self, style: BarStyle) -> Self {
103        self.bar_style = style;
104        self
105    }
106
107    /// Set color.
108    #[must_use]
109    pub fn with_color(mut self, color: Color) -> Self {
110        self.color = color;
111        self
112    }
113
114    /// Set gradient for value-based coloring.
115    #[must_use]
116    pub fn with_gradient(mut self, gradient: Gradient) -> Self {
117        self.gradient = Some(gradient);
118        self
119    }
120
121    /// Toggle axis labels.
122    #[must_use]
123    pub fn with_labels(mut self, show: bool) -> Self {
124        self.show_labels = show;
125        self
126    }
127
128    /// Update data.
129    pub fn set_data(&mut self, data: Vec<f64>) {
130        self.data = data;
131        self.compute_bins();
132    }
133
134    /// Compute bin count based on strategy.
135    #[allow(clippy::manual_clamp)]
136    fn compute_bin_count(&self) -> usize {
137        let n = self.data.len();
138        if n == 0 {
139            return 1;
140        }
141
142        match self.bins {
143            BinStrategy::Count(k) => k.max(1),
144            BinStrategy::Width(w) => {
145                let (min, max) = self.data_range();
146                ((max - min) / w).ceil() as usize
147            }
148            BinStrategy::Sturges => {
149                // Sturges: ceil(log2(n) + 1)
150                ((n as f64).log2().ceil() as usize + 1).max(1)
151            }
152            BinStrategy::Scott => {
153                // Scott: 3.49 * std / n^(1/3)
154                let std = self.std_dev();
155                if std < 1e-10 {
156                    return 1;
157                }
158                let (min, max) = self.data_range();
159                let width = 3.49 * std / (n as f64).cbrt();
160                ((max - min) / width).ceil() as usize
161            }
162            BinStrategy::FreedmanDiaconis => {
163                // Freedman-Diaconis: 2 * IQR / n^(1/3)
164                let iqr = self.iqr();
165                if iqr < 1e-10 {
166                    return 1;
167                }
168                let (min, max) = self.data_range();
169                let width = 2.0 * iqr / (n as f64).cbrt();
170                ((max - min) / width).ceil() as usize
171            }
172        }
173        .max(1)
174        .min(100) // Cap at 100 bins
175    }
176
177    /// Get data range (min, max).
178    fn data_range(&self) -> (f64, f64) {
179        let mut min = f64::INFINITY;
180        let mut max = f64::NEG_INFINITY;
181
182        for &v in &self.data {
183            if v.is_finite() {
184                min = min.min(v);
185                max = max.max(v);
186            }
187        }
188
189        if min == f64::INFINITY {
190            (0.0, 1.0)
191        } else if (max - min).abs() < 1e-10 {
192            (min - 0.5, max + 0.5)
193        } else {
194            (min, max)
195        }
196    }
197
198    /// Compute standard deviation.
199    fn std_dev(&self) -> f64 {
200        let n = self.data.len();
201        if n < 2 {
202            return 0.0;
203        }
204
205        let mean: f64 = self.data.iter().filter(|x| x.is_finite()).sum::<f64>()
206            / self.data.iter().filter(|x| x.is_finite()).count() as f64;
207
208        let variance: f64 = self
209            .data
210            .iter()
211            .filter(|x| x.is_finite())
212            .map(|x| (x - mean).powi(2))
213            .sum::<f64>()
214            / (n - 1) as f64;
215
216        variance.sqrt()
217    }
218
219    /// Compute interquartile range.
220    fn iqr(&self) -> f64 {
221        let mut sorted: Vec<f64> = self
222            .data
223            .iter()
224            .filter(|x| x.is_finite())
225            .copied()
226            .collect();
227        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
228
229        if sorted.len() < 4 {
230            return self.std_dev(); // Fall back to std dev
231        }
232
233        let q1_idx = sorted.len() / 4;
234        let q3_idx = 3 * sorted.len() / 4;
235
236        sorted[q3_idx] - sorted[q1_idx]
237    }
238
239    /// Compute bins and counts.
240    fn compute_bins(&mut self) {
241        let n_bins = self.compute_bin_count();
242        let (min, max) = self.data_range();
243        let bin_width = (max - min) / n_bins as f64;
244
245        self.computed_bins = (0..n_bins)
246            .map(|i| {
247                let start = min + i as f64 * bin_width;
248                let end = start + bin_width;
249                let count = self
250                    .data
251                    .iter()
252                    .filter(|&&v| {
253                        if i == n_bins - 1 {
254                            v >= start && v <= end
255                        } else {
256                            v >= start && v < end
257                        }
258                    })
259                    .count();
260                (start, end, count)
261            })
262            .collect();
263    }
264}
265
266impl Default for Histogram {
267    fn default() -> Self {
268        Self::new(Vec::new())
269    }
270}
271
272impl Widget for Histogram {
273    fn type_id(&self) -> TypeId {
274        TypeId::of::<Self>()
275    }
276
277    fn measure(&self, constraints: Constraints) -> Size {
278        Size::new(
279            constraints.max_width.min(60.0),
280            constraints.max_height.min(15.0),
281        )
282    }
283
284    fn layout(&mut self, bounds: Rect) -> LayoutResult {
285        self.bounds = bounds;
286        LayoutResult {
287            size: Size::new(bounds.width, bounds.height),
288        }
289    }
290
291    fn paint(&self, canvas: &mut dyn Canvas) {
292        if self.bounds.width < 5.0 || self.bounds.height < 3.0 || self.computed_bins.is_empty() {
293            return;
294        }
295
296        let max_count = self
297            .computed_bins
298            .iter()
299            .map(|(_, _, c)| *c)
300            .max()
301            .unwrap_or(1)
302            .max(1);
303
304        match self.orientation {
305            HistogramOrientation::Vertical => self.paint_vertical(canvas, max_count),
306            HistogramOrientation::Horizontal => self.paint_horizontal(canvas, max_count),
307        }
308    }
309
310    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
311        None
312    }
313
314    fn children(&self) -> &[Box<dyn Widget>] {
315        &[]
316    }
317
318    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
319        &mut []
320    }
321}
322
323impl Histogram {
324    fn paint_vertical(&self, canvas: &mut dyn Canvas, max_count: usize) {
325        let label_height = if self.show_labels { 1.0 } else { 0.0 };
326        let label_width = if self.show_labels { 5.0 } else { 0.0 };
327
328        let plot_x = self.bounds.x + label_width;
329        let plot_y = self.bounds.y;
330        let plot_width = self.bounds.width - label_width;
331        let plot_height = self.bounds.height - label_height;
332
333        let n_bins = self.computed_bins.len();
334        let bar_width = (plot_width / n_bins as f32).max(1.0);
335
336        // Draw Y axis labels
337        if self.show_labels {
338            let label_style = TextStyle {
339                color: Color::new(0.6, 0.6, 0.6, 1.0),
340                ..Default::default()
341            };
342
343            canvas.draw_text(
344                &format!("{max_count:>4}"),
345                Point::new(self.bounds.x, plot_y),
346                &label_style,
347            );
348            canvas.draw_text(
349                "   0",
350                Point::new(self.bounds.x, plot_y + plot_height - 1.0),
351                &label_style,
352            );
353        }
354
355        // Draw bars
356        for (i, &(start, _end, count)) in self.computed_bins.iter().enumerate() {
357            let bar_height = if max_count > 0 {
358                (count as f32 / max_count as f32) * plot_height
359            } else {
360                0.0
361            };
362
363            let x = plot_x + i as f32 * bar_width;
364            let y = plot_y + plot_height - bar_height;
365
366            // Determine color
367            let color = if let Some(ref gradient) = self.gradient {
368                gradient.sample(count as f64 / max_count as f64)
369            } else {
370                self.color
371            };
372
373            let style = TextStyle {
374                color,
375                ..Default::default()
376            };
377
378            // Draw bar based on style
379            match self.bar_style {
380                BarStyle::Solid => {
381                    for row in 0..(bar_height.ceil() as usize) {
382                        let bar_chars: String =
383                            (0..(bar_width as usize).max(1)).map(|_| '█').collect();
384                        canvas.draw_text(&bar_chars, Point::new(x, y + row as f32), &style);
385                    }
386                }
387                BarStyle::Blocks => {
388                    const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
389                    let full_rows = bar_height as usize;
390                    let frac = bar_height.fract();
391                    let frac_idx = ((frac * 8.0) as usize).min(7);
392
393                    for row in 0..full_rows {
394                        let bar_chars: String =
395                            (0..(bar_width as usize).max(1)).map(|_| '█').collect();
396                        canvas.draw_text(&bar_chars, Point::new(x, y + row as f32), &style);
397                    }
398
399                    if frac > 0.1 {
400                        let bar_chars: String = (0..(bar_width as usize).max(1))
401                            .map(|_| BLOCKS[frac_idx])
402                            .collect();
403                        canvas.draw_text(&bar_chars, Point::new(x, y + full_rows as f32), &style);
404                    }
405                }
406                BarStyle::Ascii => {
407                    for row in 0..(bar_height.ceil() as usize) {
408                        let bar_chars: String =
409                            (0..(bar_width as usize).max(1)).map(|_| '#').collect();
410                        canvas.draw_text(&bar_chars, Point::new(x, y + row as f32), &style);
411                    }
412                }
413            }
414
415            // Draw X axis label
416            if self.show_labels && i % 2 == 0 {
417                let label = format!("{start:.0}");
418                let label_x = x + bar_width / 2.0 - label.len() as f32 / 2.0;
419                canvas.draw_text(
420                    &label,
421                    Point::new(label_x, plot_y + plot_height),
422                    &TextStyle {
423                        color: Color::new(0.6, 0.6, 0.6, 1.0),
424                        ..Default::default()
425                    },
426                );
427            }
428        }
429    }
430
431    fn paint_horizontal(&self, canvas: &mut dyn Canvas, max_count: usize) {
432        let label_width = if self.show_labels { 6.0 } else { 0.0 };
433
434        let plot_x = self.bounds.x + label_width;
435        let plot_y = self.bounds.y;
436        let plot_width = self.bounds.width - label_width;
437        let plot_height = self.bounds.height;
438
439        let n_bins = self.computed_bins.len();
440        let bar_height = (plot_height / n_bins as f32).max(1.0);
441
442        for (i, &(start, _end, count)) in self.computed_bins.iter().enumerate() {
443            let bar_width = if max_count > 0 {
444                (count as f32 / max_count as f32) * plot_width
445            } else {
446                0.0
447            };
448
449            let x = plot_x;
450            let y = plot_y + i as f32 * bar_height;
451
452            // Determine color
453            let color = if let Some(ref gradient) = self.gradient {
454                gradient.sample(count as f64 / max_count as f64)
455            } else {
456                self.color
457            };
458
459            let style = TextStyle {
460                color,
461                ..Default::default()
462            };
463
464            // Draw label
465            if self.show_labels {
466                let label = format!("{start:>5.0}");
467                canvas.draw_text(
468                    &label,
469                    Point::new(self.bounds.x, y),
470                    &TextStyle {
471                        color: Color::new(0.6, 0.6, 0.6, 1.0),
472                        ..Default::default()
473                    },
474                );
475            }
476
477            // Draw bar
478            let bar_chars: String = (0..(bar_width.ceil() as usize).max(0))
479                .map(|_| '█')
480                .collect();
481            if !bar_chars.is_empty() {
482                canvas.draw_text(&bar_chars, Point::new(x, y), &style);
483            }
484        }
485    }
486}
487
488impl Brick for Histogram {
489    fn brick_name(&self) -> &'static str {
490        "Histogram"
491    }
492
493    fn assertions(&self) -> &[BrickAssertion] {
494        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
495        ASSERTIONS
496    }
497
498    fn budget(&self) -> BrickBudget {
499        BrickBudget::uniform(8)
500    }
501
502    fn verify(&self) -> BrickVerification {
503        let mut passed = Vec::new();
504        let mut failed = Vec::new();
505
506        if self.bounds.width >= 5.0 && self.bounds.height >= 3.0 {
507            passed.push(BrickAssertion::max_latency_ms(8));
508        } else {
509            failed.push((
510                BrickAssertion::max_latency_ms(8),
511                "Size too small".to_string(),
512            ));
513        }
514
515        BrickVerification {
516            passed,
517            failed,
518            verification_time: Duration::from_micros(5),
519        }
520    }
521
522    fn to_html(&self) -> String {
523        String::new()
524    }
525
526    fn to_css(&self) -> String {
527        String::new()
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_histogram_creation() {
537        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
538        let hist = Histogram::new(data);
539        assert!(!hist.computed_bins.is_empty());
540    }
541
542    #[test]
543    fn test_bin_strategies() {
544        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
545
546        let sturges = Histogram::new(data.clone()).with_bins(BinStrategy::Sturges);
547        assert!(!sturges.computed_bins.is_empty());
548
549        let scott = Histogram::new(data.clone()).with_bins(BinStrategy::Scott);
550        assert!(!scott.computed_bins.is_empty());
551
552        let fd = Histogram::new(data).with_bins(BinStrategy::FreedmanDiaconis);
553        assert!(!fd.computed_bins.is_empty());
554    }
555
556    #[test]
557    fn test_empty_data() {
558        let hist = Histogram::new(vec![]);
559        assert_eq!(hist.computed_bins.len(), 1);
560    }
561
562    #[test]
563    fn test_single_value() {
564        let hist = Histogram::new(vec![5.0, 5.0, 5.0]);
565        assert!(!hist.computed_bins.is_empty());
566    }
567
568    #[test]
569    fn test_histogram_assertions() {
570        let hist = Histogram::default();
571        assert!(!hist.assertions().is_empty());
572    }
573
574    #[test]
575    #[allow(clippy::field_reassign_with_default)]
576    fn test_histogram_verify() {
577        let mut hist = Histogram::default();
578        hist.bounds = Rect::new(0.0, 0.0, 60.0, 15.0);
579        assert!(hist.verify().is_valid());
580    }
581
582    #[test]
583    fn test_histogram_children() {
584        let hist = Histogram::default();
585        assert!(hist.children().is_empty());
586    }
587
588    #[test]
589    fn test_histogram_children_mut() {
590        let mut hist = Histogram::default();
591        assert!(hist.children_mut().is_empty());
592    }
593
594    #[test]
595    fn test_histogram_type_id() {
596        let hist = Histogram::default();
597        let tid = Widget::type_id(&hist);
598        assert_eq!(tid, TypeId::of::<Histogram>());
599    }
600
601    #[test]
602    fn test_histogram_measure() {
603        let hist = Histogram::new(vec![1.0, 2.0, 3.0]);
604        let size = hist.measure(Constraints::new(0.0, 100.0, 0.0, 50.0));
605        assert!(size.width > 0.0);
606        assert!(size.height > 0.0);
607    }
608
609    #[test]
610    fn test_histogram_layout() {
611        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0]);
612        let result = hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
613        assert_eq!(result.size.width, 60.0);
614        assert_eq!(result.size.height, 15.0);
615    }
616
617    #[test]
618    fn test_histogram_event() {
619        let mut hist = Histogram::default();
620        let event = Event::Resize {
621            width: 80.0,
622            height: 24.0,
623        };
624        assert!(hist.event(&event).is_none());
625    }
626
627    #[test]
628    fn test_histogram_brick_name() {
629        let hist = Histogram::default();
630        assert_eq!(hist.brick_name(), "Histogram");
631    }
632
633    #[test]
634    fn test_histogram_budget() {
635        let hist = Histogram::default();
636        let budget = hist.budget();
637        assert!(budget.layout_ms > 0);
638    }
639
640    #[test]
641    fn test_histogram_to_html() {
642        let hist = Histogram::default();
643        assert!(hist.to_html().is_empty());
644    }
645
646    #[test]
647    fn test_histogram_to_css() {
648        let hist = Histogram::default();
649        assert!(hist.to_css().is_empty());
650    }
651
652    #[test]
653    fn test_histogram_with_orientation() {
654        let hist =
655            Histogram::new(vec![1.0, 2.0]).with_orientation(HistogramOrientation::Horizontal);
656        assert!(matches!(hist.orientation, HistogramOrientation::Horizontal));
657    }
658
659    #[test]
660    fn test_histogram_with_bar_style() {
661        let hist = Histogram::new(vec![1.0, 2.0]).with_bar_style(BarStyle::Blocks);
662        assert!(matches!(hist.bar_style, BarStyle::Blocks));
663    }
664
665    #[test]
666    fn test_histogram_with_color() {
667        let hist = Histogram::new(vec![1.0, 2.0]).with_color(Color::RED);
668        assert_eq!(hist.color, Color::RED);
669    }
670
671    #[test]
672    fn test_histogram_with_gradient() {
673        let gradient = Gradient::from_hex(&["#00FF00", "#FF0000"]);
674        let hist = Histogram::new(vec![1.0, 2.0]).with_gradient(gradient);
675        assert!(hist.gradient.is_some());
676    }
677
678    #[test]
679    fn test_histogram_with_labels() {
680        let hist = Histogram::new(vec![1.0, 2.0]).with_labels(false);
681        assert!(!hist.show_labels);
682    }
683
684    #[test]
685    fn test_histogram_set_data() {
686        let mut hist = Histogram::new(vec![1.0, 2.0]);
687        hist.set_data(vec![10.0, 20.0, 30.0, 40.0, 50.0]);
688        assert!(!hist.computed_bins.is_empty());
689    }
690
691    #[test]
692    fn test_histogram_bin_count() {
693        let hist = Histogram::new(vec![1.0, 2.0, 3.0]).with_bins(BinStrategy::Count(5));
694        assert!(!hist.computed_bins.is_empty());
695    }
696
697    #[test]
698    fn test_histogram_bin_width() {
699        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
700        let hist = Histogram::new(data).with_bins(BinStrategy::Width(2.0));
701        assert!(!hist.computed_bins.is_empty());
702    }
703
704    #[test]
705    fn test_histogram_paint_vertical() {
706        use crate::{CellBuffer, DirectTerminalCanvas};
707
708        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
709        let mut buffer = CellBuffer::new(60, 15);
710        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
711
712        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
713        hist.paint(&mut canvas);
714    }
715
716    #[test]
717    fn test_histogram_paint_horizontal() {
718        use crate::{CellBuffer, DirectTerminalCanvas};
719
720        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0])
721            .with_orientation(HistogramOrientation::Horizontal);
722        let mut buffer = CellBuffer::new(60, 15);
723        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
724
725        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
726        hist.paint(&mut canvas);
727    }
728
729    #[test]
730    fn test_histogram_paint_blocks() {
731        use crate::{CellBuffer, DirectTerminalCanvas};
732
733        let mut hist =
734            Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_bar_style(BarStyle::Blocks);
735        let mut buffer = CellBuffer::new(60, 15);
736        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
737
738        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
739        hist.paint(&mut canvas);
740    }
741
742    #[test]
743    fn test_histogram_paint_ascii() {
744        use crate::{CellBuffer, DirectTerminalCanvas};
745
746        let mut hist =
747            Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_bar_style(BarStyle::Ascii);
748        let mut buffer = CellBuffer::new(60, 15);
749        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
750
751        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
752        hist.paint(&mut canvas);
753    }
754
755    #[test]
756    fn test_histogram_paint_with_gradient() {
757        use crate::{CellBuffer, DirectTerminalCanvas};
758
759        let gradient = Gradient::from_hex(&["#00FF00", "#FF0000"]);
760        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_gradient(gradient);
761        let mut buffer = CellBuffer::new(60, 15);
762        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
763
764        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
765        hist.paint(&mut canvas);
766    }
767
768    #[test]
769    fn test_histogram_paint_without_labels() {
770        use crate::{CellBuffer, DirectTerminalCanvas};
771
772        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_labels(false);
773        let mut buffer = CellBuffer::new(60, 15);
774        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
775
776        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
777        hist.paint(&mut canvas);
778    }
779
780    #[test]
781    fn test_histogram_paint_small_bounds() {
782        use crate::{CellBuffer, DirectTerminalCanvas};
783
784        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0]);
785        let mut buffer = CellBuffer::new(4, 2);
786        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
787
788        hist.layout(Rect::new(0.0, 0.0, 4.0, 2.0));
789        hist.paint(&mut canvas);
790        // Should return early due to small bounds
791    }
792
793    #[test]
794    #[allow(clippy::field_reassign_with_default)]
795    fn test_histogram_verify_small_bounds() {
796        let mut hist = Histogram::default();
797        hist.bounds = Rect::new(0.0, 0.0, 2.0, 1.0);
798        assert!(!hist.verify().is_valid());
799    }
800
801    #[test]
802    fn test_histogram_data_with_nan() {
803        let hist = Histogram::new(vec![1.0, f64::NAN, 3.0, f64::INFINITY, 5.0]);
804        assert!(!hist.computed_bins.is_empty());
805    }
806
807    #[test]
808    fn test_histogram_iqr_small_data() {
809        let hist = Histogram::new(vec![1.0, 2.0]); // Less than 4 values, falls back to std
810        assert!(!hist.computed_bins.is_empty());
811    }
812
813    #[test]
814    fn test_histogram_std_dev_single() {
815        let hist = Histogram::new(vec![5.0]);
816        assert!(!hist.computed_bins.is_empty());
817    }
818
819    #[test]
820    fn test_histogram_clone() {
821        let hist = Histogram::new(vec![1.0, 2.0, 3.0]);
822        let cloned = hist.clone();
823        assert_eq!(cloned.computed_bins.len(), hist.computed_bins.len());
824    }
825
826    #[test]
827    fn test_histogram_debug() {
828        let hist = Histogram::new(vec![1.0, 2.0, 3.0]);
829        let debug = format!("{hist:?}");
830        assert!(debug.contains("Histogram"));
831    }
832
833    #[test]
834    fn test_bin_strategy_debug() {
835        let strategy = BinStrategy::Sturges;
836        let debug = format!("{strategy:?}");
837        assert!(debug.contains("Sturges"));
838    }
839
840    #[test]
841    fn test_histogram_orientation_debug() {
842        let orientation = HistogramOrientation::Vertical;
843        let debug = format!("{orientation:?}");
844        assert!(debug.contains("Vertical"));
845    }
846
847    #[test]
848    fn test_bar_style_debug() {
849        let style = BarStyle::Solid;
850        let debug = format!("{style:?}");
851        assert!(debug.contains("Solid"));
852    }
853
854    #[test]
855    fn test_histogram_horizontal_with_gradient() {
856        use crate::{CellBuffer, DirectTerminalCanvas};
857
858        let gradient = Gradient::from_hex(&["#00FF00", "#FF0000"]);
859        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0])
860            .with_orientation(HistogramOrientation::Horizontal)
861            .with_gradient(gradient);
862        let mut buffer = CellBuffer::new(60, 15);
863        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
864
865        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
866        hist.paint(&mut canvas);
867    }
868
869    #[test]
870    fn test_histogram_horizontal_without_labels() {
871        use crate::{CellBuffer, DirectTerminalCanvas};
872
873        let mut hist = Histogram::new(vec![1.0, 2.0, 3.0, 4.0, 5.0])
874            .with_orientation(HistogramOrientation::Horizontal)
875            .with_labels(false);
876        let mut buffer = CellBuffer::new(60, 15);
877        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
878
879        hist.layout(Rect::new(0.0, 0.0, 60.0, 15.0));
880        hist.paint(&mut canvas);
881    }
882
883    #[test]
884    fn test_histogram_large_data() {
885        let data: Vec<f64> = (0..1000).map(|i| (i as f64 * 0.37) % 100.0).collect();
886        let hist = Histogram::new(data);
887        assert!(!hist.computed_bins.is_empty());
888    }
889}