Skip to main content

envision/component/histogram/
mod.rs

1//! Histogram component for frequency distribution visualization.
2//!
3//! Takes raw continuous data, automatically bins it, and displays the
4//! frequency distribution as vertical bars using ratatui's `BarChart`
5//! widget.
6//!
7//! # Adaptive Binning
8//!
9//! By default, the histogram uses a fixed bin count (10). You can choose
10//! an adaptive binning method that computes the optimal number of bins
11//! based on the data:
12//!
13//! - [`BinMethod::Fixed`] — a user-specified number of bins (default: 10).
14//! - [`BinMethod::Sturges`] — `ceil(log2(n) + 1)`, good for roughly normal data.
15//! - [`BinMethod::SquareRoot`] — `ceil(sqrt(n))`, a simple rule of thumb.
16//! - [`BinMethod::Scott`] — `ceil(range / (3.49 * std * n^(-1/3)))`, optimal for normal data.
17//! - [`BinMethod::FreedmanDiaconis`] — `ceil(range / (2 * IQR * n^(-1/3)))`, robust to outliers.
18//!
19//! # Example
20//!
21//! ```rust
22//! use envision::component::{Component, Histogram, HistogramState};
23//!
24//! let state = HistogramState::with_data(vec![1.0, 2.0, 2.5, 3.0, 3.5, 4.0]);
25//! assert_eq!(state.data().len(), 6);
26//! assert_eq!(state.bin_count(), 10);
27//! ```
28
29use std::marker::PhantomData;
30
31use ratatui::prelude::*;
32use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders};
33
34use super::{Component, EventContext, RenderContext};
35use crate::input::Event;
36
37/// Strategy for computing the number of histogram bins.
38///
39/// The default is `Fixed(10)`, which uses a static bin count. Adaptive
40/// methods compute the bin count from the data at render time so the
41/// histogram automatically adjusts as data changes.
42///
43/// All adaptive methods clamp the result to the range `[1, 200]`.
44///
45/// # Example
46///
47/// ```rust
48/// use envision::component::{BinMethod, HistogramState};
49///
50/// let state = HistogramState::with_data(vec![1.0, 2.0, 3.0, 4.0])
51///     .with_bin_method(BinMethod::Sturges);
52/// assert_eq!(state.bin_method(), &BinMethod::Sturges);
53/// ```
54#[derive(Clone, Debug, PartialEq)]
55#[cfg_attr(
56    feature = "serialization",
57    derive(serde::Serialize, serde::Deserialize)
58)]
59pub enum BinMethod {
60    /// A fixed, user-specified number of bins.
61    Fixed(usize),
62    /// Freedman-Diaconis rule: `width = 2 * IQR * n^(-1/3)`, `bins = ceil(range / width)`.
63    ///
64    /// Robust to outliers because it uses the interquartile range.
65    FreedmanDiaconis,
66    /// Sturges' formula: `ceil(log2(n) + 1)`.
67    ///
68    /// Works well for roughly normal data but can undercount bins for
69    /// large datasets.
70    Sturges,
71    /// Scott's normal reference rule: `width = 3.49 * std * n^(-1/3)`,
72    /// `bins = ceil(range / width)`.
73    ///
74    /// Optimal for data drawn from a normal distribution.
75    Scott,
76    /// Square-root rule: `ceil(sqrt(n))`.
77    ///
78    /// A simple rule of thumb used in many applications.
79    SquareRoot,
80}
81
82impl Default for BinMethod {
83    fn default() -> Self {
84        BinMethod::Fixed(10)
85    }
86}
87
88/// The minimum number of bins any adaptive method can produce.
89const MIN_BINS: usize = 1;
90
91/// The maximum number of bins any adaptive method can produce.
92const MAX_BINS: usize = 200;
93
94impl BinMethod {
95    /// Computes the effective bin count for the given data.
96    ///
97    /// For `Fixed(n)`, the value is returned directly (clamped to at least 1).
98    /// For adaptive methods, the algorithm inspects the data and clamps the
99    /// result to `[1, 200]`.
100    ///
101    /// # Example
102    ///
103    /// ```rust
104    /// use envision::component::BinMethod;
105    ///
106    /// let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
107    /// assert_eq!(BinMethod::SquareRoot.compute_bin_count(&data), 10);
108    /// assert_eq!(BinMethod::Sturges.compute_bin_count(&data), 8);
109    /// ```
110    pub fn compute_bin_count(&self, data: &[f64]) -> usize {
111        match self {
112            BinMethod::Fixed(n) => (*n).max(1),
113            BinMethod::Sturges => Self::sturges(data),
114            BinMethod::SquareRoot => Self::square_root(data),
115            BinMethod::Scott => Self::scott(data),
116            BinMethod::FreedmanDiaconis => Self::freedman_diaconis(data),
117        }
118    }
119
120    fn sturges(data: &[f64]) -> usize {
121        if data.is_empty() {
122            return MIN_BINS;
123        }
124        let n = data.len() as f64;
125        let bins = (n.log2() + 1.0).ceil() as usize;
126        bins.clamp(MIN_BINS, MAX_BINS)
127    }
128
129    fn square_root(data: &[f64]) -> usize {
130        if data.is_empty() {
131            return MIN_BINS;
132        }
133        let n = data.len() as f64;
134        let bins = n.sqrt().ceil() as usize;
135        bins.clamp(MIN_BINS, MAX_BINS)
136    }
137
138    fn scott(data: &[f64]) -> usize {
139        if data.is_empty() {
140            return MIN_BINS;
141        }
142        let n = data.len() as f64;
143        let mean = data.iter().sum::<f64>() / n;
144        let variance = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
145        let std = variance.sqrt();
146        if std < f64::EPSILON {
147            return MIN_BINS;
148        }
149        let min = data.iter().copied().reduce(f64::min).unwrap_or(0.0);
150        let max = data.iter().copied().reduce(f64::max).unwrap_or(0.0);
151        let range = max - min;
152        if range < f64::EPSILON {
153            return MIN_BINS;
154        }
155        let width = 3.49 * std * n.powf(-1.0 / 3.0);
156        if width < f64::EPSILON {
157            return MIN_BINS;
158        }
159        let bins = (range / width).ceil() as usize;
160        bins.clamp(MIN_BINS, MAX_BINS)
161    }
162
163    fn freedman_diaconis(data: &[f64]) -> usize {
164        if data.is_empty() {
165            return MIN_BINS;
166        }
167        let mut sorted = data.to_vec();
168        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
169        let n = sorted.len();
170        let q1 = sorted[n / 4];
171        let q3 = sorted[3 * n / 4];
172        let iqr = q3 - q1;
173        if iqr < f64::EPSILON {
174            return MIN_BINS;
175        }
176        let min = sorted[0];
177        let max = sorted[n - 1];
178        let range = max - min;
179        if range < f64::EPSILON {
180            return MIN_BINS;
181        }
182        let width = 2.0 * iqr * (n as f64).powf(-1.0 / 3.0);
183        if width < f64::EPSILON {
184            return MIN_BINS;
185        }
186        let bins = (range / width).ceil() as usize;
187        bins.clamp(MIN_BINS, MAX_BINS)
188    }
189}
190
191/// State for a Histogram component.
192///
193/// Contains raw data points and configuration for binning and display.
194///
195/// # Example
196///
197/// ```rust
198/// use envision::component::HistogramState;
199///
200/// let state = HistogramState::with_data(vec![10.0, 20.0, 30.0])
201///     .with_bin_count(5)
202///     .with_title("Latency Distribution");
203/// assert_eq!(state.data().len(), 3);
204/// assert_eq!(state.bin_count(), 5);
205/// assert_eq!(state.title(), Some("Latency Distribution"));
206/// ```
207#[derive(Clone, Debug, Default, PartialEq)]
208#[cfg_attr(
209    feature = "serialization",
210    derive(serde::Serialize, serde::Deserialize)
211)]
212pub struct HistogramState {
213    /// Raw data points.
214    data: Vec<f64>,
215    /// Binning strategy (default: Fixed(10)).
216    bin_method: BinMethod,
217    /// Manual minimum value (None = auto from data).
218    min_value: Option<f64>,
219    /// Manual maximum value (None = auto from data).
220    max_value: Option<f64>,
221    /// Optional title.
222    title: Option<String>,
223    /// X-axis label.
224    x_label: Option<String>,
225    /// Y-axis label.
226    y_label: Option<String>,
227    /// Bar color.
228    color: Option<Color>,
229    /// Whether to show count labels on bars.
230    show_counts: bool,
231}
232
233impl HistogramState {
234    /// Creates an empty histogram state.
235    ///
236    /// # Example
237    ///
238    /// ```rust
239    /// use envision::component::HistogramState;
240    ///
241    /// let state = HistogramState::new();
242    /// assert!(state.data().is_empty());
243    /// assert_eq!(state.bin_count(), 10);
244    /// ```
245    pub fn new() -> Self {
246        Self::default()
247    }
248
249    /// Creates a histogram state with initial data.
250    ///
251    /// # Example
252    ///
253    /// ```rust
254    /// use envision::component::HistogramState;
255    ///
256    /// let state = HistogramState::with_data(vec![1.0, 2.0, 3.0]);
257    /// assert_eq!(state.data().len(), 3);
258    /// ```
259    pub fn with_data(data: Vec<f64>) -> Self {
260        Self {
261            data,
262            ..Default::default()
263        }
264    }
265
266    /// Sets the number of bins (builder pattern).
267    ///
268    /// A bin count of 0 is treated as 1.
269    ///
270    /// # Example
271    ///
272    /// ```rust
273    /// use envision::component::HistogramState;
274    ///
275    /// let state = HistogramState::new().with_bin_count(20);
276    /// assert_eq!(state.bin_count(), 20);
277    /// ```
278    pub fn with_bin_count(mut self, count: usize) -> Self {
279        self.bin_method = BinMethod::Fixed(count.max(1));
280        self
281    }
282
283    /// Sets the binning strategy (builder pattern).
284    ///
285    /// # Example
286    ///
287    /// ```rust
288    /// use envision::component::{BinMethod, HistogramState};
289    ///
290    /// let state = HistogramState::with_data(vec![1.0, 2.0, 3.0, 4.0])
291    ///     .with_bin_method(BinMethod::Sturges);
292    /// assert_eq!(state.bin_method(), &BinMethod::Sturges);
293    /// ```
294    pub fn with_bin_method(mut self, method: BinMethod) -> Self {
295        self.bin_method = method;
296        self
297    }
298
299    /// Sets the manual range (builder pattern).
300    ///
301    /// # Example
302    ///
303    /// ```rust
304    /// use envision::component::HistogramState;
305    ///
306    /// let state = HistogramState::new().with_range(0.0, 100.0);
307    /// assert_eq!(state.effective_min(), 0.0);
308    /// assert_eq!(state.effective_max(), 100.0);
309    /// ```
310    pub fn with_range(mut self, min: f64, max: f64) -> Self {
311        self.min_value = Some(min);
312        self.max_value = Some(max);
313        self
314    }
315
316    /// Sets the title (builder pattern).
317    ///
318    /// # Example
319    ///
320    /// ```rust
321    /// use envision::component::HistogramState;
322    ///
323    /// let state = HistogramState::new().with_title("Response Times");
324    /// assert_eq!(state.title(), Some("Response Times"));
325    /// ```
326    pub fn with_title(mut self, title: impl Into<String>) -> Self {
327        self.title = Some(title.into());
328        self
329    }
330
331    /// Sets the x-axis label (builder pattern).
332    ///
333    /// # Example
334    ///
335    /// ```rust
336    /// use envision::component::HistogramState;
337    ///
338    /// let state = HistogramState::new().with_x_label("Latency (ms)");
339    /// assert_eq!(state.x_label(), Some("Latency (ms)"));
340    /// ```
341    pub fn with_x_label(mut self, label: impl Into<String>) -> Self {
342        self.x_label = Some(label.into());
343        self
344    }
345
346    /// Sets the y-axis label (builder pattern).
347    ///
348    /// # Example
349    ///
350    /// ```rust
351    /// use envision::component::HistogramState;
352    ///
353    /// let state = HistogramState::new().with_y_label("Frequency");
354    /// assert_eq!(state.y_label(), Some("Frequency"));
355    /// ```
356    pub fn with_y_label(mut self, label: impl Into<String>) -> Self {
357        self.y_label = Some(label.into());
358        self
359    }
360
361    /// Sets the bar color (builder pattern).
362    ///
363    /// # Example
364    ///
365    /// ```rust
366    /// use envision::component::HistogramState;
367    /// use ratatui::style::Color;
368    ///
369    /// let state = HistogramState::new().with_color(Color::Cyan);
370    /// assert_eq!(state.color(), Some(Color::Cyan));
371    /// ```
372    pub fn with_color(mut self, color: Color) -> Self {
373        self.color = Some(color);
374        self
375    }
376
377    /// Sets whether to show count labels on bars (builder pattern).
378    ///
379    /// # Example
380    ///
381    /// ```rust
382    /// use envision::component::HistogramState;
383    ///
384    /// let state = HistogramState::new().with_show_counts(true);
385    /// assert!(state.show_counts());
386    /// ```
387    pub fn with_show_counts(mut self, show: bool) -> Self {
388        self.show_counts = show;
389        self
390    }
391
392    // ---- Accessors ----
393
394    /// Returns the raw data points.
395    ///
396    /// # Example
397    ///
398    /// ```rust
399    /// use envision::component::HistogramState;
400    ///
401    /// let state = HistogramState::with_data(vec![1.0, 2.0, 3.0]);
402    /// assert_eq!(state.data(), &[1.0, 2.0, 3.0]);
403    /// ```
404    pub fn data(&self) -> &[f64] {
405        &self.data
406    }
407
408    /// Returns a mutable reference to the raw data points.
409    ///
410    /// This is safe because the histogram has no derived indices or
411    /// filter state; bins are recomputed on each render.
412    ///
413    /// # Example
414    ///
415    /// ```rust
416    /// use envision::component::HistogramState;
417    ///
418    /// let mut state = HistogramState::with_data(vec![1.0, 2.0, 3.0]);
419    /// state.data_mut().push(4.0);
420    /// assert_eq!(state.data().len(), 4);
421    /// ```
422    pub fn data_mut(&mut self) -> &mut Vec<f64> {
423        &mut self.data
424    }
425
426    /// Adds a single data point.
427    ///
428    /// # Example
429    ///
430    /// ```rust
431    /// use envision::component::HistogramState;
432    ///
433    /// let mut state = HistogramState::new();
434    /// state.push(42.0);
435    /// assert_eq!(state.data(), &[42.0]);
436    /// ```
437    pub fn push(&mut self, value: f64) {
438        self.data.push(value);
439    }
440
441    /// Adds multiple data points.
442    ///
443    /// # Example
444    ///
445    /// ```rust
446    /// use envision::component::HistogramState;
447    ///
448    /// let mut state = HistogramState::new();
449    /// state.push_batch(&[1.0, 2.0, 3.0]);
450    /// assert_eq!(state.data().len(), 3);
451    /// ```
452    pub fn push_batch(&mut self, values: &[f64]) {
453        self.data.extend_from_slice(values);
454    }
455
456    /// Clears all data points.
457    ///
458    /// # Example
459    ///
460    /// ```rust
461    /// use envision::component::HistogramState;
462    ///
463    /// let mut state = HistogramState::with_data(vec![1.0, 2.0]);
464    /// state.clear();
465    /// assert!(state.data().is_empty());
466    /// ```
467    pub fn clear(&mut self) {
468        self.data.clear();
469    }
470
471    /// Returns the effective number of bins.
472    pub fn bin_count(&self) -> usize {
473        self.bin_method.compute_bin_count(&self.data)
474    }
475
476    /// Sets the number of bins (convenience, sets `Fixed` method).
477    pub fn set_bin_count(&mut self, count: usize) {
478        self.bin_method = BinMethod::Fixed(count.max(1));
479    }
480
481    /// Returns the current binning method.
482    pub fn bin_method(&self) -> &BinMethod {
483        &self.bin_method
484    }
485
486    /// Sets the binning method.
487    pub fn set_bin_method(&mut self, method: BinMethod) {
488        self.bin_method = method;
489    }
490
491    /// Returns the title.
492    pub fn title(&self) -> Option<&str> {
493        self.title.as_deref()
494    }
495
496    /// Sets the title.
497    ///
498    /// # Example
499    ///
500    /// ```rust
501    /// use envision::component::HistogramState;
502    ///
503    /// let mut state = HistogramState::new();
504    /// state.set_title("Response Times");
505    /// assert_eq!(state.title(), Some("Response Times"));
506    /// ```
507    pub fn set_title(&mut self, title: impl Into<String>) {
508        self.title = Some(title.into());
509    }
510
511    /// Returns the x-axis label.
512    pub fn x_label(&self) -> Option<&str> {
513        self.x_label.as_deref()
514    }
515
516    /// Returns the y-axis label.
517    pub fn y_label(&self) -> Option<&str> {
518        self.y_label.as_deref()
519    }
520
521    /// Returns the bar color.
522    pub fn color(&self) -> Option<Color> {
523        self.color
524    }
525
526    /// Sets the bar color.
527    ///
528    /// # Example
529    ///
530    /// ```rust
531    /// use envision::component::HistogramState;
532    /// use ratatui::style::Color;
533    ///
534    /// let mut state = HistogramState::new();
535    /// state.set_color(Some(Color::Blue));
536    /// assert_eq!(state.color(), Some(Color::Blue));
537    /// ```
538    pub fn set_color(&mut self, color: Option<Color>) {
539        self.color = color;
540    }
541
542    /// Returns whether count labels are shown on bars.
543    pub fn show_counts(&self) -> bool {
544        self.show_counts
545    }
546
547    /// Sets whether count labels are shown on bars.
548    ///
549    /// # Example
550    ///
551    /// ```rust
552    /// use envision::component::HistogramState;
553    ///
554    /// let mut state = HistogramState::new();
555    /// state.set_show_counts(true);
556    /// assert!(state.show_counts());
557    /// ```
558    pub fn set_show_counts(&mut self, show: bool) {
559        self.show_counts = show;
560    }
561
562    /// Returns the effective minimum value.
563    ///
564    /// Uses the manual minimum if set, otherwise auto-computes from data.
565    /// Returns 0.0 for empty data with no manual minimum.
566    ///
567    /// # Example
568    ///
569    /// ```rust
570    /// use envision::component::HistogramState;
571    ///
572    /// let state = HistogramState::with_data(vec![5.0, 10.0, 15.0]);
573    /// assert_eq!(state.effective_min(), 5.0);
574    ///
575    /// let state = HistogramState::with_data(vec![5.0, 10.0]).with_range(0.0, 20.0);
576    /// assert_eq!(state.effective_min(), 0.0);
577    /// ```
578    pub fn effective_min(&self) -> f64 {
579        self.min_value
580            .unwrap_or_else(|| self.data.iter().copied().reduce(f64::min).unwrap_or(0.0))
581    }
582
583    /// Returns the effective maximum value.
584    ///
585    /// Uses the manual maximum if set, otherwise auto-computes from data.
586    /// Returns 0.0 for empty data with no manual maximum.
587    ///
588    /// # Example
589    ///
590    /// ```rust
591    /// use envision::component::HistogramState;
592    ///
593    /// let state = HistogramState::with_data(vec![5.0, 10.0, 15.0]);
594    /// assert_eq!(state.effective_max(), 15.0);
595    ///
596    /// let state = HistogramState::with_data(vec![5.0, 10.0]).with_range(0.0, 20.0);
597    /// assert_eq!(state.effective_max(), 20.0);
598    /// ```
599    pub fn effective_max(&self) -> f64 {
600        self.max_value
601            .unwrap_or_else(|| self.data.iter().copied().reduce(f64::max).unwrap_or(0.0))
602    }
603
604    /// Computes the bin edges and frequency counts.
605    ///
606    /// Returns a vector of `(bin_start, bin_end, count)` tuples, one for each
607    /// bin. Bins are evenly spaced from `effective_min()` to `effective_max()`.
608    ///
609    /// When all data has the same value (range is zero), a single bin is
610    /// created spanning `[value - 0.5, value + 0.5)`.
611    ///
612    /// # Example
613    ///
614    /// ```rust
615    /// use envision::component::HistogramState;
616    ///
617    /// let state = HistogramState::with_data(vec![1.0, 2.0, 3.0, 4.0, 5.0])
618    ///     .with_bin_count(5)
619    ///     .with_range(1.0, 5.0);
620    /// let bins = state.compute_bins();
621    /// assert_eq!(bins.len(), 5);
622    /// // Each bin should have a count
623    /// let total: usize = bins.iter().map(|(_, _, c)| c).sum();
624    /// assert_eq!(total, 5);
625    /// ```
626    pub fn compute_bins(&self) -> Vec<(f64, f64, usize)> {
627        let bin_count = self.bin_count().max(1);
628
629        if self.data.is_empty() {
630            let min = self.effective_min();
631            let max = self.effective_max();
632
633            if (max - min).abs() < f64::EPSILON {
634                // Zero-range: create bins around the single value
635                return vec![(min - 0.5, min + 0.5, 0); bin_count];
636            }
637
638            let bin_width = (max - min) / bin_count as f64;
639            return (0..bin_count)
640                .map(|i| {
641                    let start = min + i as f64 * bin_width;
642                    let end = min + (i + 1) as f64 * bin_width;
643                    (start, end, 0)
644                })
645                .collect();
646        }
647
648        let min = self.effective_min();
649        let max = self.effective_max();
650
651        // Handle zero range (all values are the same)
652        if (max - min).abs() < f64::EPSILON {
653            return vec![(min - 0.5, min + 0.5, self.data.len()); 1];
654        }
655
656        let bin_width = (max - min) / bin_count as f64;
657
658        let mut counts = vec![0usize; bin_count];
659
660        for &value in &self.data {
661            let bin_index = ((value - min) / bin_width).floor() as usize;
662            // Clamp to valid range; the max value falls into the last bin
663            let bin_index = bin_index.min(bin_count - 1);
664            counts[bin_index] += 1;
665        }
666
667        (0..bin_count)
668            .map(|i| {
669                let start = min + i as f64 * bin_width;
670                let end = min + (i + 1) as f64 * bin_width;
671                (start, end, counts[i])
672            })
673            .collect()
674    }
675
676    // ---- Focus / Disabled ----
677
678    // ---- Instance methods ----
679
680    /// Maps an input event to a histogram message.
681    pub fn handle_event(&self, event: &Event) -> Option<HistogramMessage> {
682        Histogram::handle_event(self, event, &EventContext::default())
683    }
684
685    /// Dispatches an event, updating state and returning any output.
686    pub fn dispatch_event(&mut self, event: &Event) -> Option<()> {
687        Histogram::dispatch_event(self, event, &EventContext::default())
688    }
689
690    /// Updates the state with a message, returning any output.
691    pub fn update(&mut self, msg: HistogramMessage) -> Option<()> {
692        Histogram::update(self, msg)
693    }
694}
695
696/// Messages that can be sent to a Histogram.
697#[derive(Clone, Debug, PartialEq)]
698#[cfg_attr(
699    feature = "serialization",
700    derive(serde::Serialize, serde::Deserialize)
701)]
702pub enum HistogramMessage {
703    /// Replace all data points.
704    SetData(Vec<f64>),
705    /// Add a single data point.
706    PushData(f64),
707    /// Add multiple data points.
708    PushDataBatch(Vec<f64>),
709    /// Clear all data.
710    Clear,
711    /// Change the number of bins (sets bin method to `Fixed`).
712    SetBinCount(usize),
713    /// Change the binning strategy.
714    SetBinMethod(BinMethod),
715    /// Set the manual min/max range.
716    SetRange(Option<f64>, Option<f64>),
717}
718
719/// A histogram component for frequency distribution visualization.
720///
721/// Takes raw continuous data, automatically bins it, and renders the
722/// frequency distribution as vertical bars.
723///
724/// This is a display-only component. It does not handle keyboard events.
725///
726/// # Example
727///
728/// ```rust
729/// use envision::component::{Component, Histogram, HistogramState};
730///
731/// let state = HistogramState::with_data(vec![1.0, 1.5, 2.0, 2.5, 3.0, 3.5])
732///     .with_bin_count(3)
733///     .with_title("Value Distribution");
734/// let bins = state.compute_bins();
735/// assert_eq!(bins.len(), 3);
736/// ```
737pub struct Histogram(PhantomData<()>);
738
739impl Component for Histogram {
740    type State = HistogramState;
741    type Message = HistogramMessage;
742    type Output = ();
743
744    fn init() -> Self::State {
745        HistogramState::default()
746    }
747
748    fn handle_event(
749        _state: &Self::State,
750        _event: &Event,
751        _ctx: &EventContext,
752    ) -> Option<Self::Message> {
753        // Display-only component; no event handling.
754        None
755    }
756
757    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
758        match msg {
759            HistogramMessage::SetData(data) => {
760                state.data = data;
761            }
762            HistogramMessage::PushData(value) => {
763                state.data.push(value);
764            }
765            HistogramMessage::PushDataBatch(values) => {
766                state.data.extend(values);
767            }
768            HistogramMessage::Clear => {
769                state.data.clear();
770            }
771            HistogramMessage::SetBinCount(count) => {
772                state.bin_method = BinMethod::Fixed(count.max(1));
773            }
774            HistogramMessage::SetBinMethod(method) => {
775                state.bin_method = method;
776            }
777            HistogramMessage::SetRange(min, max) => {
778                state.min_value = min;
779                state.max_value = max;
780            }
781        }
782        None
783    }
784
785    fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
786        if ctx.area.height < 3 || ctx.area.width < 3 {
787            return;
788        }
789
790        crate::annotation::with_registry(|reg| {
791            reg.register(
792                ctx.area,
793                crate::annotation::Annotation::container("histogram")
794                    .with_focus(ctx.focused)
795                    .with_disabled(ctx.disabled),
796            );
797        });
798
799        let border_style = if ctx.disabled {
800            ctx.theme.disabled_style()
801        } else if ctx.focused {
802            ctx.theme.focused_border_style()
803        } else {
804            ctx.theme.border_style()
805        };
806
807        let mut block = Block::default()
808            .borders(Borders::ALL)
809            .border_style(border_style);
810
811        if let Some(ref title) = state.title {
812            block = block.title(title.as_str());
813        }
814
815        let inner = block.inner(ctx.area);
816        ctx.frame.render_widget(block, ctx.area);
817
818        if inner.height == 0 || inner.width == 0 {
819            return;
820        }
821
822        // Reserve space for axis labels
823        let x_label_height = if state.x_label.is_some() { 1u16 } else { 0 };
824        let y_label_height = if state.y_label.is_some() { 1u16 } else { 0 };
825
826        let (chart_area, x_label_area, y_label_area) = if x_label_height > 0 || y_label_height > 0 {
827            let chunks = Layout::default()
828                .direction(Direction::Vertical)
829                .constraints([
830                    Constraint::Length(y_label_height),
831                    Constraint::Min(1),
832                    Constraint::Length(x_label_height),
833                ])
834                .split(inner);
835            (
836                chunks[1],
837                if x_label_height > 0 {
838                    Some(chunks[2])
839                } else {
840                    None
841                },
842                if y_label_height > 0 {
843                    Some(chunks[0])
844                } else {
845                    None
846                },
847            )
848        } else {
849            (inner, None, None)
850        };
851
852        // Render y-axis label above the chart
853        if let Some(y_area) = y_label_area {
854            if let Some(ref label) = state.y_label {
855                let p = ratatui::widgets::Paragraph::new(label.as_str())
856                    .alignment(Alignment::Left)
857                    .style(Style::default().fg(Color::DarkGray));
858                ctx.frame.render_widget(p, y_area);
859            }
860        }
861
862        // Render x-axis label below the chart
863        if let Some(x_area) = x_label_area {
864            if let Some(ref label) = state.x_label {
865                let p = ratatui::widgets::Paragraph::new(label.as_str())
866                    .alignment(Alignment::Center)
867                    .style(Style::default().fg(Color::DarkGray));
868                ctx.frame.render_widget(p, x_area);
869            }
870        }
871
872        // Compute bins and render bar chart
873        let bins = state.compute_bins();
874        let max_count = bins.iter().map(|(_, _, c)| *c).max().unwrap_or(0);
875
876        let bar_color = state.color.unwrap_or(Color::Cyan);
877        let bar_style = if ctx.disabled {
878            ctx.theme.disabled_style()
879        } else {
880            Style::default().fg(bar_color)
881        };
882
883        let bars: Vec<Bar> = bins
884            .iter()
885            .map(|(start, end, count)| {
886                let label = format!("{:.0}", (start + end) / 2.0);
887                let mut bar = Bar::default()
888                    .value(*count as u64)
889                    .label(Line::from(label))
890                    .style(bar_style);
891                if state.show_counts {
892                    bar = bar.text_value(format!("{}", count));
893                }
894                bar
895            })
896            .collect();
897
898        let bar_group = BarGroup::default().bars(&bars);
899
900        // Calculate bar width based on available space
901        let bin_count = bins.len() as u16;
902        let bar_width = if bin_count > 0 {
903            // Each bar needs bar_width + gap (1). Total = bin_count * (bar_width + 1) - 1
904            // Solve for bar_width: bar_width = (available + 1) / bin_count - 1
905            let available = chart_area.width;
906            let width = (available.saturating_add(1)) / bin_count.max(1);
907            width.saturating_sub(1).max(1)
908        } else {
909            1
910        };
911
912        let chart = BarChart::default()
913            .data(bar_group)
914            .bar_width(bar_width)
915            .bar_gap(1)
916            .bar_style(bar_style)
917            .max(max_count as u64);
918
919        ctx.frame.render_widget(chart, chart_area);
920    }
921}
922
923#[cfg(test)]
924mod tests;