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;