use super::chart_common::{Axis, ChartGrid, ChartOrientation, Legend};
use super::chart_render::{fill_background, render_title};
use super::chart_stats::{self, mean, median};
use crate::layout::Rect;
use crate::render::Cell;
use crate::style::Color;
use crate::utils::{char_width, truncate_to_width};
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
pub use super::chart_stats::{BinConfig, HistogramBin};
pub struct Histogram {
data: Vec<f64>,
bins: Vec<HistogramBin>,
bin_config: BinConfig,
orientation: ChartOrientation,
x_axis: Axis,
y_axis: Axis,
legend: Legend,
grid: ChartGrid,
fill_color: Color,
bar_border: Option<Color>,
cumulative: bool,
density: bool,
show_stats: bool,
title: Option<String>,
bg_color: Option<Color>,
props: WidgetProps,
}
impl Default for Histogram {
fn default() -> Self {
Self::new(&[])
}
}
impl Histogram {
pub fn new(data: &[f64]) -> Self {
let mut hist = Self {
data: data.to_vec(),
bins: Vec::new(),
bin_config: BinConfig::Auto,
orientation: ChartOrientation::Vertical,
x_axis: Axis::default(),
y_axis: Axis::default(),
legend: Legend::none(),
grid: ChartGrid::new().y(true),
fill_color: Color::rgb(97, 175, 239),
bar_border: None,
cumulative: false,
density: false,
show_stats: false,
title: None,
bg_color: None,
props: WidgetProps::new(),
};
hist.compute_bins();
hist
}
pub fn data(mut self, data: &[f64]) -> Self {
self.data = data.to_vec();
self.compute_bins();
self
}
pub fn bins(mut self, config: BinConfig) -> Self {
self.bin_config = config;
self.compute_bins();
self
}
pub fn bin_count(mut self, count: usize) -> Self {
self.bin_config = BinConfig::Count(count);
self.compute_bins();
self
}
pub fn bin_width(mut self, width: f64) -> Self {
self.bin_config = BinConfig::Width(width);
self.compute_bins();
self
}
pub fn orientation(mut self, orientation: ChartOrientation) -> Self {
self.orientation = orientation;
self
}
pub fn horizontal(mut self) -> Self {
self.orientation = ChartOrientation::Horizontal;
self
}
pub fn vertical(mut self) -> Self {
self.orientation = ChartOrientation::Vertical;
self
}
pub fn x_axis(mut self, axis: Axis) -> Self {
self.x_axis = axis;
self
}
pub fn y_axis(mut self, axis: Axis) -> Self {
self.y_axis = axis;
self
}
pub fn legend(mut self, legend: Legend) -> Self {
self.legend = legend;
self
}
pub fn grid(mut self, grid: ChartGrid) -> Self {
self.grid = grid;
self
}
pub fn fill_color(mut self, color: Color) -> Self {
self.fill_color = color;
self
}
pub fn color(mut self, color: Color) -> Self {
self.fill_color = color;
self
}
pub fn bar_border(mut self, color: Color) -> Self {
self.bar_border = Some(color);
self
}
pub fn cumulative(mut self, enabled: bool) -> Self {
self.cumulative = enabled;
self
}
pub fn density(mut self, enabled: bool) -> Self {
self.density = enabled;
self
}
pub fn show_stats(mut self, enabled: bool) -> Self {
self.show_stats = enabled;
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg_color = Some(color);
self
}
fn compute_bins(&mut self) {
self.bins = chart_stats::compute_bins(&self.data, &self.bin_config);
}
pub fn mean(&self) -> Option<f64> {
mean(&self.data)
}
pub fn median(&self) -> Option<f64> {
median(&self.data)
}
fn max_value(&self) -> f64 {
if self.cumulative {
1.0
} else if self.density {
self.bins
.iter()
.map(|b| b.density)
.fold(0.0, f64::max)
.max(0.001)
} else {
self.bins.iter().map(|b| b.count).max().unwrap_or(1) as f64
}
}
fn bin_value(&self, bin: &HistogramBin, cumulative_sum: f64) -> f64 {
if self.cumulative {
cumulative_sum
} else if self.density {
bin.density
} else {
bin.count as f64
}
}
fn render_bars(&self, ctx: &mut RenderContext, chart_area: Rect) {
if self.bins.is_empty() {
return;
}
let x_min = self.bins.first().map(|b| b.start).unwrap_or(0.0);
let x_max = self.bins.last().map(|b| b.end).unwrap_or(1.0);
let x_range = (x_max - x_min).max(1.0);
let y_max = self.max_value();
let mut cumulative_sum = 0.0;
for bin in &self.bins {
cumulative_sum += bin.frequency;
let value = self.bin_value(bin, cumulative_sum);
let bar_x_start = ((bin.start - x_min) / x_range * chart_area.width as f64) as u16;
let bar_x_end = ((bin.end - x_min) / x_range * chart_area.width as f64) as u16;
let bar_width = (bar_x_end - bar_x_start).max(1);
let bar_height = ((value / y_max) * chart_area.height as f64) as u16;
let bar_height = bar_height.min(chart_area.height);
for dx in 0..bar_width {
for dy in 0..bar_height {
let x = chart_area.x + bar_x_start + dx;
let y = chart_area.y + chart_area.height - 1 - dy;
if x < chart_area.x + chart_area.width && y >= chart_area.y {
let ch = if dy == bar_height - 1 {
'â–€'
} else if self.bar_border.is_some() && (dx == 0 || dx == bar_width - 1) {
'│'
} else {
'â–ˆ'
};
let mut cell = Cell::new(ch);
if self.bar_border.is_some() && (dx == 0 || dx == bar_width - 1) {
cell.fg = self.bar_border;
} else {
cell.fg = Some(self.fill_color);
}
ctx.set(x, y, cell);
}
}
}
}
}
fn render_stats(&self, ctx: &mut RenderContext, chart_area: Rect) {
if !self.show_stats || self.bins.is_empty() {
return;
}
let x_min = self.bins.first().map(|b| b.start).unwrap_or(0.0);
let x_max = self.bins.last().map(|b| b.end).unwrap_or(1.0);
let x_range = (x_max - x_min).max(1.0);
if let Some(mean_val) = mean(&self.data) {
let x = chart_area.x + ((mean_val - x_min) / x_range * chart_area.width as f64) as u16;
if x >= chart_area.x && x < chart_area.x + chart_area.width {
for y in chart_area.y..chart_area.y + chart_area.height {
let mut cell = Cell::new('│');
cell.fg = Some(Color::rgb(224, 108, 117)); ctx.set(x, y, cell);
}
if x + 1 < chart_area.x + chart_area.width {
let mut cell = Cell::new('μ');
cell.fg = Some(Color::rgb(224, 108, 117));
ctx.set(x + 1, chart_area.y, cell);
}
}
}
if let Some(median_val) = median(&self.data) {
let x =
chart_area.x + ((median_val - x_min) / x_range * chart_area.width as f64) as u16;
if x >= chart_area.x && x < chart_area.x + chart_area.width {
for y in chart_area.y..chart_area.y + chart_area.height {
let mut cell = Cell::new('┊');
cell.fg = Some(Color::rgb(152, 195, 121)); ctx.set(x, y, cell);
}
if x + 1 < chart_area.x + chart_area.width {
let mut cell = Cell::new('M');
cell.fg = Some(Color::rgb(152, 195, 121));
ctx.set(x + 1, chart_area.y, cell);
}
}
}
}
fn render_axes(&self, ctx: &mut RenderContext, area: Rect) {
if self.bins.is_empty() {
return;
}
let x_min = self.bins.first().map(|b| b.start).unwrap_or(0.0);
let x_max = self.bins.last().map(|b| b.end).unwrap_or(1.0);
let y_max = self.max_value();
let y_label_width = 6u16;
for i in 0..=4 {
let value = y_max * (1.0 - i as f64 / 4.0);
let label = if self.density || self.cumulative {
format!("{:.2}", value)
} else {
format!("{:.0}", value)
};
let y = area.y + 1 + (i as u16 * (area.height - 3) / 4);
let label_truncated = truncate_to_width(&label, y_label_width as usize - 1);
let mut dx: u16 = 0;
for ch in label_truncated.chars() {
let x = area.x + dx;
if x < area.x + y_label_width && y < area.y + area.height {
let mut cell = Cell::new(ch);
cell.fg = Some(self.y_axis.color);
ctx.set(x, y, cell);
}
dx += char_width(ch) as u16;
}
}
let chart_width = area.width.saturating_sub(y_label_width);
for i in 0..=4 {
let value = x_min + (x_max - x_min) * i as f64 / 4.0;
let label = self.x_axis.format_value(value);
let x = area.x + y_label_width + (i as u16 * chart_width / 4);
let y = area.y + area.height - 1;
let label_truncated = truncate_to_width(&label, 6);
let mut dx: u16 = 0;
for ch in label_truncated.chars() {
let label_x = x + dx;
if label_x < area.x + area.width {
let mut cell = Cell::new(ch);
cell.fg = Some(self.x_axis.color);
ctx.set(label_x, y, cell);
}
dx += char_width(ch) as u16;
}
}
}
}
impl View for Histogram {
crate::impl_view_meta!("Histogram");
fn render(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if area.width < 15 || area.height < 5 {
return;
}
let rel_area = Rect::new(0, 0, area.width, area.height);
if let Some(bg) = self.bg_color {
fill_background(ctx, rel_area, bg);
}
let title_offset = render_title(ctx, rel_area, self.title.as_deref(), Color::WHITE);
let y_label_width = 6u16;
let x_label_height = 1u16;
let chart_area = Rect {
x: y_label_width,
y: title_offset,
width: area.width.saturating_sub(y_label_width + 1),
height: area
.height
.saturating_sub(title_offset + x_label_height + 1),
};
if chart_area.width < 5 || chart_area.height < 3 {
return;
}
self.render_bars(ctx, chart_area);
self.render_stats(ctx, chart_area);
self.render_axes(ctx, rel_area);
}
}
impl_styled_view!(Histogram);
impl_props_builders!(Histogram);
pub fn histogram(data: &[f64]) -> Histogram {
Histogram::new(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_histogram_render_basic() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let data: Vec<f64> = (0..50)
.map(|x| (x as f64) + (x as f64).sin() * 5.0)
.collect();
let mut buffer = Buffer::new(40, 20);
let area = Rect::new(0, 0, 40, 20);
let mut ctx = RenderContext::new(&mut buffer, area);
let hist = Histogram::new(&data).bin_count(10);
hist.render(&mut ctx);
let mut has_bars = false;
for y in 0..20 {
for x in 0..40 {
if let Some(cell) = buffer.get(x, y) {
if cell.symbol == 'â–ˆ' || cell.symbol == 'â–“' || cell.symbol == 'â–’' {
has_bars = true;
break;
}
}
}
}
assert!(has_bars);
}
#[test]
fn test_histogram_render_with_title() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(40, 20);
let area = Rect::new(0, 0, 40, 20);
let mut ctx = RenderContext::new(&mut buffer, area);
let hist = Histogram::new(&[1.0, 2.0, 3.0, 4.0, 5.0]).title("Test Distribution");
hist.render(&mut ctx);
let mut title_found = false;
for x in 0..40 {
if let Some(cell) = buffer.get(x, 0) {
if cell.symbol == 'T' {
title_found = true;
break;
}
}
}
assert!(title_found);
}
#[test]
fn test_histogram_render_with_stats() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(50, 25);
let area = Rect::new(0, 0, 50, 25);
let mut ctx = RenderContext::new(&mut buffer, area);
let data: Vec<f64> = (0..100).map(|x| x as f64).collect();
let hist = Histogram::new(&data).show_stats(true).bin_count(10);
hist.render(&mut ctx);
let mut has_content = false;
for y in 0..25 {
for x in 0..50 {
if let Some(cell) = buffer.get(x, y) {
if cell.symbol != ' ' {
has_content = true;
break;
}
}
}
}
assert!(has_content);
}
#[test]
fn test_histogram_render_density() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(40, 20);
let area = Rect::new(0, 0, 40, 20);
let mut ctx = RenderContext::new(&mut buffer, area);
let data: Vec<f64> = (0..50).map(|x| x as f64).collect();
let hist = Histogram::new(&data).density(true);
hist.render(&mut ctx);
let mut has_content = false;
for y in 0..20 {
for x in 0..40 {
if let Some(cell) = buffer.get(x, y) {
if cell.symbol != ' ' {
has_content = true;
break;
}
}
}
}
assert!(has_content);
}
#[test]
fn test_histogram_render_cumulative() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(40, 20);
let area = Rect::new(0, 0, 40, 20);
let mut ctx = RenderContext::new(&mut buffer, area);
let data: Vec<f64> = (0..50).map(|x| x as f64).collect();
let hist = Histogram::new(&data).cumulative(true);
hist.render(&mut ctx);
let mut has_content = false;
for y in 0..20 {
for x in 0..40 {
if let Some(cell) = buffer.get(x, y) {
if cell.symbol != ' ' {
has_content = true;
break;
}
}
}
}
assert!(has_content);
}
#[test]
fn test_histogram_render_small_area() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(10, 3);
let area = Rect::new(0, 0, 10, 3);
let mut ctx = RenderContext::new(&mut buffer, area);
let hist = Histogram::new(&[1.0, 2.0, 3.0]);
hist.render(&mut ctx);
}
#[test]
fn test_histogram_render_empty() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(30, 15);
let area = Rect::new(0, 0, 30, 15);
let mut ctx = RenderContext::new(&mut buffer, area);
let hist = Histogram::new(&[]);
hist.render(&mut ctx);
}
#[test]
fn test_histogram_render_with_grid() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(40, 20);
let area = Rect::new(0, 0, 40, 20);
let mut ctx = RenderContext::new(&mut buffer, area);
let data: Vec<f64> = (0..50).map(|x| x as f64).collect();
let hist = Histogram::new(&data).grid(ChartGrid::both());
hist.render(&mut ctx);
let mut has_content = false;
for y in 0..20 {
for x in 0..40 {
if let Some(cell) = buffer.get(x, y) {
if cell.symbol != ' ' {
has_content = true;
break;
}
}
}
}
assert!(has_content);
}
#[test]
fn test_histogram_render_custom_bins() {
use crate::layout::Rect;
use crate::render::Buffer;
use crate::widget::traits::RenderContext;
let mut buffer = Buffer::new(40, 20);
let area = Rect::new(0, 0, 40, 20);
let mut ctx = RenderContext::new(&mut buffer, area);
let data: Vec<f64> = (0..100).map(|x| x as f64).collect();
let hist = Histogram::new(&data).bins(BinConfig::Edges(vec![0.0, 25.0, 50.0, 75.0, 100.0]));
hist.render(&mut ctx);
let mut has_content = false;
for y in 0..20 {
for x in 0..40 {
if let Some(cell) = buffer.get(x, y) {
if cell.symbol != ' ' {
has_content = true;
break;
}
}
}
}
assert!(has_content);
}
}