use std::fmt;
use crate::cells::cell_len;
use crate::console::{Console, ConsoleOptions, Renderable};
use crate::measure::Measurement;
use crate::segment::Segment;
use crate::style::Style;
const BARS: [char; 8] = [
'\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}', ];
const AXIS: char = '\u{2500}';
const DEFAULT_BINS: usize = 10;
const DEFAULT_HEIGHT: usize = 6;
#[derive(Debug, Clone)]
pub struct Histogram {
samples: Vec<f64>,
bins: usize,
range: Option<(f64, f64)>,
height: usize,
style: Style,
show_axis: bool,
}
impl Histogram {
pub fn new(samples: &[f64]) -> Self {
Self {
samples: samples.to_vec(),
bins: DEFAULT_BINS,
range: None,
height: DEFAULT_HEIGHT,
style: Style::null(),
show_axis: false,
}
}
#[must_use]
pub fn with_bins(mut self, bins: usize) -> Self {
self.bins = bins;
self
}
#[must_use]
pub fn with_range(mut self, min: f64, max: f64) -> Self {
self.range = Some((min, max));
self
}
#[must_use]
pub fn with_height(mut self, rows: usize) -> Self {
self.height = rows;
self
}
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_show_axis(mut self, show: bool) -> Self {
self.show_axis = show;
self
}
pub fn counts(&self) -> Vec<usize> {
if self.samples.is_empty() || self.bins == 0 {
return Vec::new();
}
let bins = self.bins;
let (min, max) = self.effective_range();
let mut counts = vec![0usize; bins];
let span = max - min;
if span <= 0.0 {
counts[0] = self.samples.len();
return counts;
}
let bin_width = span / bins as f64;
for &v in &self.samples {
let clamped = v.clamp(min, max);
let idx = if clamped >= max {
bins - 1
} else {
(((clamped - min) / bin_width).floor() as usize).min(bins - 1)
};
counts[idx] += 1;
}
counts
}
fn effective_range(&self) -> (f64, f64) {
if let Some(range) = self.range {
return range;
}
let min = self.samples.iter().copied().fold(f64::INFINITY, f64::min);
let max = self
.samples
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
(min, max)
}
fn column_count(&self) -> usize {
if self.samples.is_empty() {
0
} else {
self.bins
}
}
fn column_eighths(&self) -> Vec<usize> {
let counts = self.counts();
let max_count = counts.iter().copied().max().unwrap_or(0);
if max_count == 0 {
return vec![0; counts.len()];
}
let ceiling = self.height * 8;
counts
.iter()
.map(|&c| {
let frac = c as f64 / max_count as f64;
((frac * ceiling as f64).round() as usize).min(ceiling)
})
.collect()
}
fn fmt_axis(value: f64) -> String {
let s = format!("{value:.2}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
fn push_row(&self, eighths: &[usize], row: usize, segments: &mut Vec<Segment>) {
let mut glyphs: Vec<char> = Vec::with_capacity(eighths.len());
for &e in eighths {
let full = e / 8;
let partial = e % 8;
let ch = if row < full {
BARS[7] } else if row == full && partial > 0 {
BARS[partial - 1]
} else {
' '
};
glyphs.push(ch);
}
let mut run = String::new();
let mut run_blank = true;
let mut started = false;
for &ch in &glyphs {
let blank = ch == ' ';
if started && blank == run_blank {
run.push(ch);
} else {
if started {
segments.push(Self::run_segment(&run, run_blank, &self.style));
}
run.clear();
run.push(ch);
run_blank = blank;
started = true;
}
}
if started {
segments.push(Self::run_segment(&run, run_blank, &self.style));
}
segments.push(Segment::line());
}
fn run_segment(run: &str, blank: bool, style: &Style) -> Segment {
if blank {
Segment::new(run, None, None)
} else {
Segment::new(run, Some(style.clone()), None)
}
}
fn render_columns(&self) -> Vec<Segment> {
let eighths = self.column_eighths();
if eighths.is_empty() {
return vec![Segment::line()];
}
let mut segments = Vec::with_capacity(self.height + 2);
for row in (0..self.height).rev() {
self.push_row(&eighths, row, &mut segments);
}
if self.show_axis {
let width = eighths.len();
let baseline: String = std::iter::repeat_n(AXIS, width).collect();
segments.push(Segment::new(&baseline, Some(self.style.clone()), None));
segments.push(Segment::line());
let (min, max) = self.effective_range();
let lo = Self::fmt_axis(min);
let hi = Self::fmt_axis(max);
let used = cell_len(&lo) + cell_len(&hi);
let label = if used < width {
let mut s = String::with_capacity(width);
s.push_str(&lo);
s.push_str(&" ".repeat(width - used));
s.push_str(&hi);
s
} else {
format!("{lo} {hi}")
};
segments.push(Segment::new(&label, None, None));
segments.push(Segment::line());
}
segments
}
fn compute_measure(&self) -> Measurement {
let w = self.column_count();
Measurement::new(w, w)
}
}
impl fmt::Display for Histogram {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut console = Console::builder()
.width(f.width().unwrap_or(80))
.force_terminal(true)
.no_color(true)
.build();
console.begin_capture();
console.print(self);
let output = console.end_capture();
write!(f, "{}", output.trim_end_matches('\n'))
}
}
impl Renderable for Histogram {
fn gilt_console(&self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
self.render_columns()
}
fn gilt_measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
self.compute_measure()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::console::{Console, ConsoleDimensions, ConsoleOptions};
fn make_options(max_width: usize) -> ConsoleOptions {
ConsoleOptions {
size: ConsoleDimensions {
width: max_width,
height: 25,
},
legacy_windows: false,
min_width: 1,
max_width,
is_terminal: false,
encoding: std::borrow::Cow::Borrowed("utf-8"),
max_height: 25,
justify: None,
overflow: None,
no_wrap: None,
highlight: None,
markup: None,
height: None,
}
}
fn render_rows(hist: &Histogram) -> Vec<String> {
let console = Console::builder().width(80).build();
let opts = make_options(80);
let segs = hist.gilt_console(&console, &opts);
let joined: String = segs.iter().map(|s| s.text.as_str()).collect();
joined
.split('\n')
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect()
}
#[test]
fn known_samples_bucket_as_expected() {
let hist = Histogram::new(&[1.0, 2.0, 2.0, 3.0, 3.0, 3.0]).with_bins(3);
assert_eq!(hist.counts(), vec![1, 2, 3]);
}
#[test]
fn with_bins_changes_bin_count() {
let data = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
assert_eq!(Histogram::new(&data).with_bins(2).counts().len(), 2);
assert_eq!(Histogram::new(&data).with_bins(5).counts().len(), 5);
assert_eq!(Histogram::new(&data).with_bins(2).counts(), vec![5, 5]);
}
#[test]
fn with_range_clamps_out_of_range() {
let hist = Histogram::new(&[-5.0, -1.0, 1.0, 100.0])
.with_bins(5)
.with_range(0.0, 10.0);
let counts = hist.counts();
assert_eq!(counts.len(), 5);
assert_eq!(counts[0], 3); assert_eq!(counts[4], 1); }
#[test]
fn value_at_max_lands_in_last_bin() {
let hist = Histogram::new(&[0.0, 1.0, 2.0, 3.0, 4.0])
.with_bins(4)
.with_range(0.0, 4.0);
let counts = hist.counts();
assert_eq!(counts.len(), 4);
assert_eq!(counts[3], 2);
assert_eq!(counts.iter().sum::<usize>(), 5);
}
#[test]
fn all_equal_samples_no_div_by_zero() {
let hist = Histogram::new(&[5.0, 5.0, 5.0, 5.0]).with_bins(4);
let counts = hist.counts();
assert_eq!(counts, vec![4, 0, 0, 0]);
let _ = render_rows(&hist);
}
#[test]
fn empty_input_no_panic() {
let hist = Histogram::new(&[]);
assert_eq!(hist.counts(), Vec::<usize>::new());
let console = Console::builder().width(80).build();
let opts = make_options(80);
let segs = hist.gilt_console(&console, &opts);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text.as_str(), "\n");
}
#[test]
fn zero_bins_empty_render() {
let hist = Histogram::new(&[1.0, 2.0, 3.0]).with_bins(0);
assert!(hist.counts().is_empty());
let console = Console::builder().width(80).build();
let opts = make_options(80);
let segs = hist.gilt_console(&console, &opts);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].text.as_str(), "\n");
}
#[test]
fn tallest_bin_reaches_full_height() {
let hist = Histogram::new(&[1.0, 2.0, 2.0, 3.0, 3.0, 3.0])
.with_bins(3)
.with_height(4);
let rows = render_rows(&hist);
assert_eq!(rows.len(), 4, "one line per height row");
for (i, row) in rows.iter().enumerate() {
let col: Vec<char> = row.chars().collect();
assert_eq!(col[2], BARS[7], "tallest column full at row {i}");
}
}
#[test]
fn rows_are_bins_wide() {
let hist = Histogram::new(&[0.0, 1.0, 2.0, 3.0, 4.0, 5.0])
.with_bins(6)
.with_height(3);
for row in render_rows(&hist) {
assert_eq!(row.chars().count(), 6);
}
}
#[test]
fn height_controls_row_count() {
let hist = Histogram::new(&[1.0, 2.0, 3.0, 4.0])
.with_bins(4)
.with_height(9);
assert_eq!(render_rows(&hist).len(), 9);
}
#[test]
fn empty_bins_are_blank() {
let hist = Histogram::new(&[0.0, 0.0, 10.0, 10.0])
.with_bins(5)
.with_range(0.0, 10.0)
.with_height(2);
let rows = render_rows(&hist);
let bottom: Vec<char> = rows.last().unwrap().chars().collect();
assert_eq!(bottom[0], BARS[7]);
assert_eq!(bottom[4], BARS[7]);
assert_eq!(bottom[2], ' ');
}
#[test]
fn axis_adds_baseline_and_labels() {
let hist = Histogram::new(&[0.0, 5.0, 10.0])
.with_bins(8)
.with_height(3)
.with_range(0.0, 10.0)
.with_show_axis(true);
let rows = render_rows(&hist);
assert_eq!(rows.len(), 5);
assert!(rows[3].chars().all(|c| c == AXIS), "baseline is a rule");
assert!(rows[4].contains('0'));
assert!(rows[4].contains("10"));
}
#[test]
fn fmt_axis_is_compact() {
assert_eq!(Histogram::fmt_axis(3.0), "3");
assert_eq!(Histogram::fmt_axis(3.5), "3.5");
assert_eq!(Histogram::fmt_axis(0.0), "0");
assert_eq!(Histogram::fmt_axis(10.0), "10");
assert_eq!(Histogram::fmt_axis(3.14159), "3.14");
}
#[test]
fn style_applies_to_blocks_only() {
let style = Style::parse("bold red");
let hist = Histogram::new(&[0.0, 0.0, 1.0])
.with_bins(2)
.with_range(0.0, 1.0)
.with_height(2)
.with_style(style.clone());
let console = Console::builder().width(80).build();
let opts = make_options(80);
let segs = hist.gilt_console(&console, &opts);
let mut saw_styled_block = false;
for seg in &segs {
if seg.text.as_str() == "\n" {
continue;
}
if seg.text.chars().any(|c| c != ' ') {
assert_eq!(seg.style.as_ref(), Some(&style));
saw_styled_block = true;
} else {
assert!(seg.style.is_none(), "blank padding stays unstyled");
}
}
assert!(saw_styled_block);
}
#[test]
fn render_ends_in_newline() {
let hist = Histogram::new(&[1.0, 2.0, 3.0]).with_bins(3);
let console = Console::builder().width(80).build();
let opts = make_options(80);
let segs = hist.gilt_console(&console, &opts);
assert_eq!(segs.last().unwrap().text.as_str(), "\n");
}
#[test]
fn measure_matches_rendered_width() {
let hist = Histogram::new(&[0.0, 1.0, 2.0, 3.0, 4.0]).with_bins(7);
let console = Console::builder().width(80).build();
let opts = make_options(80);
let rows = render_rows(&hist);
let rendered = rows.iter().map(|r| cell_len(r)).max().unwrap();
let m = hist.gilt_measure(&console, &opts);
assert_eq!(m, Measurement::new(7, 7));
assert_eq!(m.maximum, rendered);
}
#[test]
fn measure_empty_is_zero() {
let hist = Histogram::new(&[]);
let console = Console::builder().width(80).build();
let opts = make_options(80);
assert_eq!(hist.gilt_measure(&console, &opts), Measurement::new(0, 0));
}
#[test]
fn builder_chaining() {
let hist = Histogram::new(&[1.0, 2.0])
.with_bins(12)
.with_range(0.0, 5.0)
.with_height(8)
.with_style(Style::parse("green"))
.with_show_axis(true);
assert_eq!(hist.bins, 12);
assert_eq!(hist.range, Some((0.0, 5.0)));
assert_eq!(hist.height, 8);
assert!(hist.show_axis);
}
#[test]
fn display_is_multiline_trimmed() {
let hist = Histogram::new(&[0.0, 1.0, 1.0, 2.0, 2.0, 2.0])
.with_bins(3)
.with_height(3);
let s = hist.to_string();
assert!(s.contains(BARS[7]));
assert!(!s.ends_with('\n'));
assert_eq!(s.split('\n').count(), 3);
}
}