use std::ops::Range;
use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
use super::axis::exact_labels;
use super::scale::{along, clean};
use super::{
Charset, Line, Scale, ValueFormat, cell_units, cells, has_colour, lines_to_segments,
theme_style, truncate, user_style,
};
mod stacked;
const EIGHTHS: [char; 7] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉'];
const FULL_BLOCK: char = '█';
const BRAILLE_FULL: char = '⣿';
const BRAILLE_HALF: char = '⡇';
const ASCII_FULL: char = '#';
const ASCII_HALF: char = '=';
const LOWER_EIGHTHS: [char; 7] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇'];
const BRAILLE_LOWER_HALF: char = '⣤';
const ASCII_LOWER_HALF: char = '.';
const DEFAULT_COLUMN: usize = 8;
const MAX_COLUMN: usize = u16::MAX as usize;
const MAX_THICKNESS: usize = 3;
const MAX_SIDE: usize = u16::MAX as usize;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Orientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Columns {
slot: usize,
gap: usize,
group_gap: usize,
thickness: usize,
labels: bool,
values: bool,
shown: usize,
}
const MIN_BAR: usize = 4;
const DEFAULT_BAR: usize = 40;
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Bar {
pub label: String,
pub value: f64,
pub style: Option<String>,
pub text: Option<String>,
pub value_style: Option<String>,
pub label_style: Option<String>,
}
impl Bar {
pub fn new(label: impl Into<String>, value: f64) -> Self {
Bar {
label: label.into(),
value,
style: None,
text: None,
value_style: None,
label_style: None,
}
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
pub fn value_style(mut self, style: impl Into<String>) -> Self {
self.value_style = Some(style.into());
self
}
pub fn label_style(mut self, style: impl Into<String>) -> Self {
self.label_style = Some(style.into());
self
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct BarGroup {
label: String,
bars: Vec<Bar>,
stacked: bool,
}
impl BarGroup {
pub fn new(label: impl Into<String>) -> Self {
BarGroup {
label: label.into(),
bars: Vec::new(),
stacked: false,
}
}
pub fn stacked(mut self) -> Self {
self.stacked = true;
self
}
pub fn bar(mut self, bar: Bar) -> Self {
self.bars.push(bar);
self
}
pub fn bars(mut self, bars: impl IntoIterator<Item = Bar>) -> Self {
self.bars.extend(bars);
self
}
}
#[derive(Clone, Debug, PartialEq)]
struct Group {
label: String,
len: usize,
loose: bool,
stacked: bool,
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct BarChart {
bars: Vec<Bar>,
groups: Vec<Group>,
min: Option<f64>,
max: Option<f64>,
charset: Charset,
bar_width: Option<usize>,
show_values: bool,
format: ValueFormat,
style: Option<String>,
orientation: Orientation,
thickness: Option<usize>,
bar_gap: Option<usize>,
group_gap: Option<usize>,
label_style: Option<String>,
value_style: Option<String>,
values_inside: bool,
legend: bool,
}
impl Default for BarChart {
fn default() -> Self {
Self::new()
}
}
impl BarChart {
pub fn new() -> Self {
BarChart {
bars: Vec::new(),
groups: Vec::new(),
min: None,
max: None,
charset: Charset::Auto,
bar_width: None,
show_values: true,
format: ValueFormat::Compact,
style: None,
orientation: Orientation::Horizontal,
thickness: None,
bar_gap: None,
group_gap: None,
label_style: None,
value_style: None,
values_inside: false,
legend: true,
}
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
pairs
.into_iter()
.fold(Self::new(), |chart, (label, value)| chart.bar(label, value))
}
pub fn bar(self, label: impl Into<String>, value: f64) -> Self {
self.push(Bar::new(label, value))
}
pub fn push(mut self, bar: Bar) -> Self {
self.bars.push(bar);
match self.groups.last_mut() {
Some(group) if group.loose => group.len += 1,
Some(_) => self.groups.push(Group {
label: String::new(),
len: 1,
loose: true,
stacked: false,
}),
None => {}
}
self
}
pub fn group(mut self, group: BarGroup) -> Self {
if group.bars.is_empty() {
return self;
}
if self.groups.is_empty() && !self.bars.is_empty() {
self.groups.push(Group {
label: String::new(),
len: self.bars.len(),
loose: false,
stacked: false,
});
}
self.groups.push(Group {
label: group.label,
len: group.bars.len(),
loose: false,
stacked: group.stacked,
});
self.bars.extend(group.bars);
self
}
pub fn bars(&self) -> &[Bar] {
&self.bars
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub fn max(mut self, max: f64) -> Self {
self.max = Some(max);
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.charset = charset;
self
}
pub fn orientation(mut self, orientation: Orientation) -> Self {
self.orientation = orientation;
self
}
pub fn bar_width(mut self, width: usize) -> Self {
self.bar_width = Some(width.max(1));
self
}
pub fn thickness(mut self, thickness: usize) -> Self {
self.thickness = Some(thickness.clamp(1, MAX_SIDE));
self
}
pub fn bar_gap(mut self, gap: usize) -> Self {
self.bar_gap = Some(gap.min(MAX_SIDE));
self
}
pub fn group_gap(mut self, gap: usize) -> Self {
self.group_gap = Some(gap.min(MAX_SIDE));
self
}
pub fn show_values(mut self, show: bool) -> Self {
self.show_values = show;
self
}
pub fn values_inside(mut self, inside: bool) -> Self {
self.values_inside = inside;
self
}
pub fn format(mut self, format: ValueFormat) -> Self {
self.format = format;
self
}
pub fn legend(mut self, legend: bool) -> Self {
self.legend = legend;
self
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn label_style(mut self, style: impl Into<String>) -> Self {
self.label_style = Some(style.into());
self
}
pub fn value_style(mut self, style: impl Into<String>) -> Self {
self.value_style = Some(style.into());
self
}
fn spans(&self) -> Vec<(&str, Range<usize>)> {
let mut out = Vec::new();
let mut at = 0;
for group in &self.groups {
let end = (at + group.len).min(self.bars.len());
out.push((group.label.as_str(), at..end));
at = end;
}
if at < self.bars.len() {
out.push(("", at..self.bars.len()));
}
out.retain(|(_, range)| !range.is_empty());
out
}
fn scale(&self) -> Scale {
let values = self.bars.iter().map(|b| b.value);
let data = Scale::from_values(values);
let data = if self.bars.iter().any(|b| b.value.is_finite()) {
data.include_zero()
} else {
data
};
data.bounds(self.min, self.max)
}
fn label_width(&self) -> usize {
self.bars.iter().map(|b| cells(&b.label)).max().unwrap_or(0)
}
fn value_text(&self, bar: &Bar) -> String {
bar.text
.clone()
.unwrap_or_else(|| self.format.format(bar.value))
}
fn value_width(&self) -> usize {
if !self.show_values {
return 0;
}
self.bars
.iter()
.map(|b| cells(&self.value_text(b)))
.max()
.unwrap_or(0)
}
fn value_room(&self) -> usize {
if self.values_inside {
0
} else {
self.value_width()
}
}
fn natural_bar(&self) -> usize {
self.bar_width.unwrap_or(match self.orientation {
Orientation::Horizontal => DEFAULT_BAR,
Orientation::Vertical => DEFAULT_COLUMN,
})
}
fn gap(&self) -> usize {
self.bar_gap.unwrap_or(match self.orientation {
Orientation::Horizontal => 0,
Orientation::Vertical => 1,
})
}
fn group_space(&self, groups: usize) -> usize {
if groups < 2 {
return 0;
}
self.group_gap.unwrap_or(match self.orientation {
Orientation::Horizontal => 2,
Orientation::Vertical => 1,
})
}
fn columns(&self, width: usize) -> Columns {
let n = self.bars.len().max(1);
let groups = self.spans().len().max(1);
let label = self.label_width();
let value = self.value_room();
let gap = self.gap();
let group_gap = self.group_space(groups);
let floor = self.thickness.unwrap_or(1);
let gaps = (n - 1)
.saturating_mul(gap)
.saturating_add((groups - 1).saturating_mul(group_gap));
let fits = |slot: usize| n.saturating_mul(slot).saturating_add(gaps) <= width;
let full = |slot: usize, labels: bool, values: bool| Columns {
slot,
gap,
group_gap,
thickness: self
.thickness
.map_or(slot.min(MAX_THICKNESS), |t| t.min(slot)),
labels,
values,
shown: n,
};
let values = self.value_width() > 0;
let with_values = label.max(value).max(floor);
if fits(with_values) {
return full(with_values, label > 0, values);
}
let without = label.max(floor);
if fits(without) {
return full(without, label > 0, values && self.values_inside);
}
if self.thickness.is_some() || self.bar_gap.is_some() || self.group_gap.is_some() {
if fits(floor) {
let slot = (width - gaps) / n;
return full(slot, label > 0 && slot > 1, values && self.values_inside);
}
let mut shown = 0;
for (group, (_, range)) in self.spans().iter().enumerate() {
for index in range.clone() {
let left = index
.saturating_mul(floor.saturating_add(gap))
.saturating_add(group.saturating_mul(group_gap));
if left.saturating_add(floor) > width {
break;
}
shown = index + 1;
}
}
let shows_values = self.values_inside || value <= floor;
return Columns {
shown: shown.max(1).min(self.bars.len()),
..full(floor, label > 0 && floor > 1, values && shows_values)
};
}
if width >= gaps.saturating_add(n) {
let slot = (width - gaps) / n;
return full(slot, label > 0 && slot > 1, false);
}
Columns {
slot: 1,
gap: 0,
group_gap: 0,
thickness: 1,
labels: false,
values: false,
shown: n.min(width.max(1)),
}
}
fn column_glyphs(charset: Charset, length: f64, sliver: bool) -> Vec<char> {
let full = length.floor() as usize;
let rest = length - full as f64;
let mut out = Vec::new();
match charset {
Charset::Ascii | Charset::Braille => {
let (whole, half) = if charset == Charset::Ascii {
(ASCII_FULL, ASCII_LOWER_HALF)
} else {
(BRAILLE_FULL, BRAILLE_LOWER_HALF)
};
let halves = (rest * 2.0).round() as usize;
out.extend(std::iter::repeat_n(whole, full + halves / 2));
if halves == 1 || (out.is_empty() && sliver) {
out.push(half);
}
}
_ => {
let eighths = (rest * 8.0).round() as usize;
out.extend(std::iter::repeat_n(FULL_BLOCK, full + eighths / 8));
if !eighths.is_multiple_of(8) {
out.push(LOWER_EIGHTHS[eighths % 8 - 1]);
} else if out.is_empty() && sliver {
out.push(LOWER_EIGHTHS[0]);
}
}
}
out
}
fn bar_style(&self, console: &Console, bar: &Bar, negative: bool) -> Style {
match (&bar.style, &self.style) {
(Some(s), _) => user_style(console, s),
(None, _) if negative => theme_style(console, "chart.negative"),
(None, Some(s)) => user_style(console, s),
(None, None) => theme_style(console, "chart.bar"),
}
}
fn layered(
console: &Console,
base: &str,
chart: &Option<String>,
bar: Option<&Option<String>>,
) -> Style {
let mut style = theme_style(console, base);
for spec in [Some(chart), bar].into_iter().flatten().flatten() {
style = style.combine(&user_style(console, spec));
}
style
}
fn label_style_of(&self, console: &Console, bar: &Bar) -> Style {
Self::layered(
console,
"chart.label",
&self.label_style,
Some(&bar.label_style),
)
}
fn value_style_of(&self, console: &Console, bar: &Bar) -> Style {
Self::layered(
console,
"chart.value",
&self.value_style,
Some(&bar.value_style),
)
}
fn inside_style_of(&self, console: &Console, bar: &Bar, bar_style: &Style) -> Style {
if self.value_style.is_none() && bar.value_style.is_none() {
bar_style.combine(&Style::parse("reverse").unwrap_or_default())
} else {
self.value_style_of(console, bar)
}
}
fn group_style(&self, console: &Console) -> Style {
Self::layered(console, "chart.label", &self.label_style, None)
}
fn vertical_fit(
&self,
console: &Console,
width: usize,
charset: Charset,
height: Option<usize>,
) -> Vec<Line> {
match (self.bar_width, height) {
(Some(rows), _) => self.vertical_lines(console, width, charset, rows.min(MAX_COLUMN)),
(None, None) => self.vertical_lines(console, width, charset, DEFAULT_COLUMN),
(None, Some(total)) => {
let total = total.min(MAX_COLUMN);
let mut rows = total.max(1);
loop {
let lines = self.vertical_lines(console, width, charset, rows);
if lines.len() <= total || rows == 1 {
return lines;
}
rows -= (lines.len() - total).min(rows - 1);
}
}
}
}
fn vertical_lines(
&self,
console: &Console,
width: usize,
charset: Charset,
height: usize,
) -> Vec<Line> {
let ascii = charset == Charset::Ascii;
let colour = has_colour(console);
let layout = self.columns(width);
let spans = self.spans();
let mut lefts = Vec::with_capacity(layout.shown);
for (group, (_, range)) in spans.iter().enumerate() {
for index in range.clone().take_while(|i| *i < layout.shown) {
lefts.push(index * (layout.slot + layout.gap) + group * layout.group_gap);
}
}
let width = lefts.last().map_or(0, |l| l + layout.slot).min(width);
let scale = self.scale();
let zero = scale.normalize(0.0).unwrap_or(0.0) * height as f64;
let zero_row = (zero.round() as usize).min(height);
let full = match charset {
Charset::Ascii => ASCII_FULL,
Charset::Braille => BRAILLE_FULL,
_ => FULL_BLOCK,
};
let (bottom, top) = (-1i64, height as i64);
let rows = (top - bottom + 1) as usize;
let mut grid: Vec<Vec<(String, Option<Style>)>> =
vec![vec![(" ".to_string(), None); width]; rows];
let mut put = |row: i64, col: usize, cell: &str, style: Option<Style>| {
if (bottom..=top).contains(&row) && col < width {
grid[(top - row) as usize][col] = (cell.to_string(), style);
}
};
let mut used = (0i64, height as i64 - 1);
for (index, bar) in self.bars.iter().take(layout.shown).enumerate() {
let left = lefts[index];
let bar_left = left + (layout.slot - layout.thickness) / 2;
let negative = bar.value < 0.0;
let style = colour.then(|| self.bar_style(console, bar, negative));
let (value_row, base_row) = match scale.normalize(bar.value) {
None => (zero_row as i64, None),
Some(n) if negative => {
let start = (n * height as f64).round() as usize;
let len = zero_row.saturating_sub(start).max(1).min(zero_row);
let glyph = full.to_string();
for row in zero_row - len..zero_row {
for col in bar_left..bar_left + layout.thickness {
put(row as i64, col, &glyph, style.clone());
}
}
let base = (len > 0).then_some(zero_row as i64 - 1);
((zero_row - len) as i64 - 1, base)
}
Some(n) => {
let length = (n * height as f64 - zero).max(0.0);
let glyphs = Self::column_glyphs(charset, length, bar.value > 0.0);
let drawn = glyphs.len().min(height - zero_row);
for (i, glyph) in glyphs.iter().take(drawn).enumerate() {
let glyph = glyph.to_string();
for col in bar_left..bar_left + layout.thickness {
put((zero_row + i) as i64, col, &glyph, style.clone());
}
}
let base = (drawn > 0).then_some(zero_row as i64);
((zero_row + drawn) as i64, base)
}
};
if !layout.values {
continue;
}
let value = truncate(&self.value_text(bar), usize::MAX, ascii);
if self.values_inside {
let Some(row) = base_row else {
continue;
};
let text = if cells(&value) <= layout.thickness {
value
} else if layout.thickness > 1 {
truncate(&value, layout.thickness, ascii)
} else {
continue;
};
let start = bar_left + (layout.thickness - cells(&text)) / 2;
let value_style = colour.then(|| {
self.inside_style_of(console, bar, style.as_ref().unwrap_or(&Style::new()))
});
for (i, unit) in cell_units(&text).iter().enumerate() {
put(row, start + i, unit, value_style.clone());
}
} else {
let start = left + (layout.slot - cells(&value)) / 2;
let value_style = colour.then(|| self.value_style_of(console, bar));
for (i, unit) in cell_units(&value).iter().enumerate() {
put(value_row, start + i, unit, value_style.clone());
}
used = (used.0.min(value_row), used.1.max(value_row));
}
}
let mut out: Vec<Line> = grid
.into_iter()
.enumerate()
.filter(|(i, _)| (used.0..=used.1).contains(&(top - *i as i64)))
.map(|(_, row)| {
let mut line = Line::new();
for (cell, style) in row {
line.push(&cell, style);
}
line
})
.collect();
if layout.labels {
let mut line = Line::new();
for (index, bar) in self.bars.iter().take(layout.shown).enumerate() {
line.pad(lefts[index].saturating_sub(line.width()));
let label = truncate(&bar.label, layout.slot, ascii);
let pad = layout.slot - cells(&label);
line.pad(pad / 2);
line.push(&label, colour.then(|| self.label_style_of(console, bar)));
line.pad(pad - pad / 2);
}
line.pad(width.saturating_sub(line.width()));
out.push(line);
}
if spans.iter().any(|(label, _)| !label.is_empty()) {
let style = colour.then(|| self.group_style(console));
let mut line = Line::new();
for (label, range) in &spans {
let shown: Vec<usize> = range.clone().take_while(|i| *i < layout.shown).collect();
let (Some(&first), Some(&last)) = (shown.first(), shown.last()) else {
continue;
};
let (start, end) = (lefts[first], lefts[last] + layout.slot);
let room = end - start;
if room < 2 && cells(label) > room {
continue;
}
let text = truncate(label, room, ascii);
line.pad(start.saturating_sub(line.width()));
let pad = room - cells(&text);
line.pad(pad / 2);
line.push(&text, style.clone());
line.pad(pad - pad / 2);
}
line.pad(width.saturating_sub(line.width()));
out.push(line);
}
out
}
fn layout(&self, width: usize) -> (usize, usize, usize) {
let mut label = self.label_width();
let mut value = self.value_room();
let gap = |w: usize| usize::from(w > 0);
let fixed = |l: usize, v: usize| l + gap(l) + v + gap(v);
let natural = self.natural_bar();
let floor = MIN_BAR.min(natural);
if width >= fixed(label, value) + floor {
let bar = natural.min(width - fixed(label, value));
return (label, bar, value);
}
value = 0;
if width >= fixed(label, 0) + floor {
let bar = natural.min(width - fixed(label, 0));
return (label, bar, value);
}
label = width.saturating_sub(floor + 1).min(label);
let bar = width
.saturating_sub(fixed(label, 0))
.max(1)
.min(width.max(1));
(label, bar, value)
}
pub(crate) fn glyphs(charset: Charset, length: f64, sliver: bool) -> String {
let full = length.floor() as usize;
let rest = length - full as f64;
let mut out = String::new();
match charset {
Charset::Ascii | Charset::Braille => {
let (whole, half) = if charset == Charset::Ascii {
(ASCII_FULL, ASCII_HALF)
} else {
(BRAILLE_FULL, BRAILLE_HALF)
};
let halves = (rest * 2.0).round() as usize;
let full = full + halves / 2;
out.extend(std::iter::repeat_n(whole, full));
if halves == 1 {
out.push(half);
}
if out.is_empty() && sliver {
out.push(half);
}
}
_ => {
let eighths = (rest * 8.0).round() as usize;
let full = full + eighths / 8;
out.extend(std::iter::repeat_n(FULL_BLOCK, full));
if !eighths.is_multiple_of(8) {
out.push(EIGHTHS[eighths % 8 - 1]);
}
if out.is_empty() && sliver {
out.push(EIGHTHS[0]);
}
}
}
out
}
fn bar_cells(
&self,
bar: &Bar,
scale: &Scale,
charset: Charset,
(bar_w, zero, zero_cell): (usize, f64, usize),
style: Option<Style>,
) -> (Vec<(String, Option<Style>)>, usize, usize) {
let blank = || (" ".to_string(), None);
let mut row = vec![blank(); bar_w];
match scale.normalize(bar.value) {
None => (row, zero_cell.min(bar_w), 0),
Some(n) if bar.value < 0.0 => {
let start = (n * bar_w as f64).round() as usize;
let len = zero_cell.saturating_sub(start).max(1).min(zero_cell);
let start = zero_cell - len;
let glyph = match charset {
Charset::Ascii => ASCII_FULL,
Charset::Braille => BRAILLE_FULL,
_ => FULL_BLOCK,
};
for cell in &mut row[start..zero_cell.min(bar_w)] {
*cell = (glyph.to_string(), style.clone());
}
(row, start, len)
}
Some(n) => {
let length = (n * bar_w as f64 - zero).max(0.0);
let glyphs = Self::glyphs(charset, length, bar.value > 0.0);
let start = zero_cell.min(bar_w);
let drawn = cells(&glyphs).min(bar_w - start);
for (i, glyph) in glyphs.chars().take(drawn).enumerate() {
row[start + i] = (glyph.to_string(), style.clone());
}
(row, start, (length.floor() as usize).min(drawn))
}
}
}
fn horizontal_lines(&self, console: &Console, width: usize, charset: Charset) -> Vec<Line> {
let ascii = charset == Charset::Ascii;
let colour = has_colour(console);
let (label_w, bar_w, value_w) = self.layout(width);
let scale = self.scale();
let zero = scale.normalize(0.0).unwrap_or(0.0) * bar_w as f64;
let zero_cell = zero.round() as usize;
let thickness = self.thickness.unwrap_or(1);
let middle = thickness / 2;
let spans = self.spans();
let gap = self.gap();
let group_gap = self.group_space(spans.len());
let mut out = Vec::new();
for (group, (group_label, range)) in spans.iter().enumerate() {
for (nth, bar) in self.bars[range.clone()].iter().enumerate() {
if nth > 0 {
out.extend((0..gap).map(|_| Line::new()));
}
let negative = bar.value < 0.0;
let style = colour.then(|| self.bar_style(console, bar, negative));
let (cells_, start, whole) = self.bar_cells(
bar,
&scale,
charset,
(bar_w, zero, zero_cell),
style.clone(),
);
for row in 0..thickness {
let here = row == middle;
let mut line = Line::new();
if label_w > 0 {
if here {
let label = truncate(&bar.label, label_w, ascii);
let pad = label_w - cells(&label);
line.push(&label, colour.then(|| self.label_style_of(console, bar)));
line.pad(pad + 1);
} else {
line.pad(label_w + 1);
}
}
let mut cells_ = cells_.clone();
if here && self.values_inside && self.show_values && bar.value.is_finite() {
let text = truncate(&self.value_text(bar), usize::MAX, ascii);
let on_bar = colour.then(|| {
self.inside_style_of(
console,
bar,
style.as_ref().unwrap_or(&Style::new()),
)
});
let units = cell_units(&text);
for (i, unit) in units.iter().enumerate() {
let col = start + i;
if col >= bar_w {
break;
}
let wide =
units.get(i + 1).is_some_and(String::is_empty) && !unit.is_empty();
let cell_style = if i < whole {
on_bar.clone()
} else {
style.clone()
};
if wide && col + 1 >= bar_w {
cells_[col] = (" ".to_string(), cell_style);
break;
}
cells_[col] = (unit.clone(), cell_style);
}
}
for (cell, cell_style) in cells_ {
line.push(&cell, cell_style);
}
if value_w > 0 {
if here {
let value = truncate(&self.value_text(bar), value_w, ascii);
line.pad(1 + value_w - cells(&value));
line.push(&value, colour.then(|| self.value_style_of(console, bar)));
} else {
line.pad(1 + value_w);
}
}
out.push(line);
}
}
let mut space = if group + 1 < spans.len() {
gap + group_gap
} else {
0
};
if !group_label.is_empty() {
let mut line = Line::new();
if label_w > 0 {
line.pad(label_w + 1);
}
let room = bar_w + if value_w > 0 { value_w + 1 } else { 0 };
line.push(
&truncate(group_label, room, ascii),
colour.then(|| self.group_style(console)),
);
out.push(line);
space = space.saturating_sub(1);
}
out.extend((0..space).map(|_| Line::new()));
}
out
}
pub(crate) fn render_lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
let width = options.max_width;
let charset = self.charset.resolve(console, options, Charset::Blocks);
let ascii = charset == Charset::Ascii;
if self.bars.is_empty() {
let mut line = Line::new();
line.push(&truncate("no data", width, ascii), None);
return vec![line];
}
if self.is_stacked() {
return self.stacked_lines(console, width, charset, options.height);
}
match self.orientation {
Orientation::Vertical => self.vertical_fit(console, width, charset, options.height),
Orientation::Horizontal => self.horizontal_lines(console, width, charset),
}
}
fn natural_width(&self) -> usize {
if self.is_stacked() {
return self.totals().natural_width();
}
if self.orientation == Orientation::Vertical {
let n = self.bars.len();
let groups = self.spans().len().max(1);
let slot = self
.label_width()
.max(self.value_room())
.max(self.thickness.unwrap_or(1));
let gaps = n
.saturating_sub(1)
.saturating_mul(self.gap())
.saturating_add((groups - 1).saturating_mul(self.group_space(groups)));
return n.saturating_mul(slot).saturating_add(gaps);
}
let l = self.label_width();
let v = self.value_room();
(l + usize::from(l > 0) + v + usize::from(v > 0)).saturating_add(self.natural_bar())
}
}
impl crate::surface::Draw for BarChart {
fn draw(
&self,
console: &Console,
options: &ConsoleOptions,
screen: &mut crate::surface::Screen,
rect: crate::surface::Rect,
) {
super::draw_lines(
self.render_lines(console, options),
options.max_width,
options,
screen,
rect,
);
}
}
impl Renderable for BarChart {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
lines_to_segments(self.render_lines(console, options), options.max_width)
}
fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
if self.bars.is_empty() {
return Measurement::new(7, 7).with_maximum(options.max_width);
}
let max = self.natural_width();
let min = match self.orientation {
Orientation::Horizontal => MIN_BAR,
Orientation::Vertical if self.is_stacked() => self.stacks().len(),
Orientation::Vertical => self.bars.len(),
};
Measurement::new(min.min(max), max)
.with_maximum(options.max_width)
.normalize()
}
}
impl crate::a11y::AccessibleText for BarChart {
fn accessible_text(&self, _width: usize) -> String {
if self.is_stacked() {
return self.stacked_facts();
}
self.facts("Bar chart", ("bar", "bars"))
}
}
impl BarChart {
pub(crate) fn value_extent(&self) -> Option<(f64, f64)> {
let any = self.bars.iter().any(|b| b.value.is_finite());
if !any && self.min.is_none() && self.max.is_none() {
return None;
}
let scale = if self.is_stacked() {
self.stacked_scale()
} else {
self.scale()
};
Some((scale.min(), scale.max()))
}
fn facts(&self, what: &str, noun: (&str, &str)) -> String {
use super::describe::{counted, extent, range, ranked};
if self.bars.is_empty() {
return format!("{what}, no {}", noun.1);
}
let mut head = format!("{what}, {}", counted(self.bars.len(), noun.0, noun.1));
let groups: Vec<(&str, Range<usize>)> = self
.spans()
.into_iter()
.filter(|_| !self.groups.is_empty())
.collect();
if groups.len() > 1 {
head.push_str(&format!(" in {} groups", groups.len()));
}
if let Some((lo, hi)) = extent(self.bars.iter().map(|b| b.value)) {
head.push_str(&format!(", values {}", range(lo, hi, self.format)));
}
let missing = self.bars.iter().filter(|b| !b.value.is_finite()).count();
if missing > 0 {
head.push_str(&format!(
", {} without a value",
super::describe::count(missing)
));
}
let group_of = |i: usize| {
groups
.iter()
.find(|(_, r)| r.contains(&i))
.map_or("", |(label, _)| label)
};
let entries: Vec<(String, String, f64)> = self
.bars
.iter()
.enumerate()
.map(|(i, bar)| {
let name = match group_of(i) {
"" => bar.label.clone(),
group => format!("{group} {}", bar.label).trim_end().to_string(),
};
let text = if bar.value.is_finite() {
self.value_text(bar)
} else {
"no value".to_string()
};
(name, text, bar.value)
})
.collect();
let mut out = vec![head];
out.extend(ranked(&entries, 6));
out.join("\n")
}
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Histogram {
values: Vec<f64>,
bins: usize,
min: Option<f64>,
max: Option<f64>,
chart: BarChart,
}
impl Histogram {
pub fn new(values: impl IntoIterator<Item = f64>) -> Self {
Histogram {
values: values.into_iter().collect(),
bins: 10,
min: None,
max: None,
chart: BarChart::new(),
}
}
pub fn bins(mut self, bins: usize) -> Self {
self.bins = bins.max(1);
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.chart = self.chart.charset(charset);
self
}
pub fn orientation(mut self, orientation: Orientation) -> Self {
self.chart = self.chart.orientation(orientation);
self
}
pub fn bar_width(mut self, width: usize) -> Self {
self.chart = self.chart.bar_width(width);
self
}
pub fn show_values(mut self, show: bool) -> Self {
self.chart = self.chart.show_values(show);
self
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.chart = self.chart.style(style);
self
}
pub fn edges(&self) -> Vec<f64> {
let data = Scale::from_values(self.values.iter().copied());
let bins = self.bins as f64;
let split = |scale: Scale| -> Vec<f64> {
(0..=self.bins)
.map(|i| clean(scale.lerp(i as f64 / bins)))
.collect()
};
if self.min.is_some() || self.max.is_some() {
let scale = data.bounds(self.min, self.max);
let step = scale.span() / bins;
if (scale.max() - scale.min()).is_finite() {
return (0..=self.bins)
.map(|i| clean(scale.min() + step * i as f64))
.collect();
}
return split(scale);
}
let mut step = bin_step(data.span() / bins);
let mut start = (data.min() / step).floor() * step;
while step.is_finite() && along(start, bins, step) < data.max() {
step = bin_step(step * 1.001);
start = (data.min() / step).floor() * step;
}
if !(start.is_finite() && along(start, bins, step).is_finite()) {
return split(data);
}
(0..=self.bins)
.map(|i| clean(along(start, i as f64, step)))
.collect()
}
pub fn counts(&self) -> Vec<usize> {
let edges = self.edges();
let (lo, hi) = (edges[0], edges[self.bins]);
let inner = &edges[1..self.bins];
let mut counts = vec![0; self.bins];
for v in self.values.iter().copied().filter(|v| v.is_finite()) {
if v < lo || v > hi {
continue;
}
let bin = inner.partition_point(|&edge| edge <= v);
counts[bin] += 1;
}
counts
}
pub fn to_bar_chart(&self) -> BarChart {
if !self.values.iter().any(|v| v.is_finite()) {
return BarChart {
bars: Vec::new(),
..self.chart.clone()
};
}
let edges = self.edges();
let written = edge_labels(&edges);
let counts = self.counts();
let mut chart = BarChart {
bars: Vec::new(),
..self.chart.clone()
};
for (i, count) in counts.into_iter().enumerate() {
let close = if i + 1 == self.bins { ']' } else { ')' };
let label = format!("[{}, {}{close}", written[i], written[i + 1]);
chart.bars.push(Bar::new(label, count as f64));
}
chart
}
}
impl Renderable for Histogram {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
self.to_bar_chart().rich_render(console, options)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
self.to_bar_chart().measure(console, options)
}
}
fn edge_labels(edges: &[f64]) -> Vec<String> {
exact_labels(ValueFormat::Compact, edges)
.map(|(labels, _)| labels)
.unwrap_or_else(|| {
edges
.iter()
.map(|&e| ValueFormat::Compact.format(e))
.collect()
})
}
impl crate::a11y::AccessibleText for Histogram {
fn accessible_text(&self, _width: usize) -> String {
use super::describe::{count, counted, ranked};
let finite = self.values.iter().filter(|v| v.is_finite()).count();
let ignored = self.values.len() - finite;
let mut head = format!("Histogram, {}", counted(finite, "value", "values"));
if finite == 0 {
if ignored > 0 {
head.push_str(&format!(", {} ignored", count(ignored)));
}
return head;
}
let written = edge_labels(&self.edges());
let chart = self.to_bar_chart();
head.push_str(&format!(
" in {} from {} to {}",
counted(self.bins, "bin", "bins"),
written[0],
written[self.bins]
));
if ignored > 0 {
head.push_str(&format!(", {} ignored", count(ignored)));
}
let entries: Vec<(String, String, f64)> = chart
.bars
.iter()
.map(|b| (b.label.clone(), count(b.value as usize), b.value))
.collect();
let mut out = vec![head];
out.extend(ranked(&entries, 6));
out.join("\n")
}
}
fn bin_step(x: f64) -> f64 {
if !(x.is_finite() && x > 0.0) {
return 1.0;
}
let power = 10f64.powf(x.log10().floor());
[1.0, 2.0, 2.5, 5.0, 10.0]
.into_iter()
.map(|m| m * power)
.find(|step| *step >= x * (1.0 - 1e-12))
.unwrap_or(10.0 * power)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_explicit_thickness_and_gap_keep_and_leave_bars_off() {
let labels = "abcdefghijklmnopqrstuvwx";
let bars = |chart: BarChart| {
labels
.chars()
.fold(chart, |c, l| c.bar(l.to_string(), 5.0))
.max(5.0)
.orientation(Orientation::Vertical)
.show_values(false)
.bar_width(2)
};
let kept = bars(BarChart::new()).thickness(3).bar_gap(2);
let row = ["███"; 8].join(" ");
assert_eq!(
rows(40, &kept),
vec![
row.clone(),
row,
" a b c d e f g h ".to_string(),
]
);
let squeezed = bars(BarChart::new());
assert_eq!(rows(40, &squeezed)[0], "█".repeat(24));
let narrow = bars(BarChart::new()).thickness(1).bar_gap(1);
assert_eq!(rows(47, &narrow)[0], ["█"; 24].join(" "));
let group = |label: &str| {
BarGroup::new("").bars((0..3).map(|i| Bar::new(format!("{label}{i}"), 1.0)))
};
let grouped = ["p", "q", "r", "s"]
.into_iter()
.fold(BarChart::new(), |c, l| c.group(group(l)))
.max(1.0)
.orientation(Orientation::Vertical)
.show_values(false)
.bar_width(1)
.thickness(2)
.bar_gap(1)
.group_gap(3);
assert_eq!(rows(20, &grouped)[0], "██ ██ ██ ██ ██ ██");
}
#[test]
fn accessible_text_lists_few_bars_and_ranks_many() {
use crate::a11y::AccessibleText;
let chart = BarChart::new()
.bar("api", 412.0)
.bar("web", 268.5)
.bar("worker", 97.0)
.push(Bar::new("cron", 12.0).text("12 jobs"));
assert_eq!(
chart.accessible_text(80),
"Bar chart, 4 bars, values from 12 to 412\n\
api 412, web 268.5, worker 97, cron 12 jobs"
);
let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"];
let values = [30.0, 42.0, 35.0, 51.0, 48.0, 20.0, 44.0];
let many = BarChart::from_pairs(months.into_iter().zip(values));
assert_eq!(
many.accessible_text(80),
"Bar chart, 7 bars, values from 20 to 51\n\
Largest: Apr 51, May 48, Jul 44\n\
Smallest: Jun 20"
);
let grouped = BarChart::new()
.group(
BarGroup::new("Q1")
.bar(Bar::new("a", 4.0))
.bar(Bar::new("b", 2.0)),
)
.group(
BarGroup::new("Q2")
.bar(Bar::new("a", 3.0))
.bar(Bar::new("b", f64::NAN)),
);
assert_eq!(
grouped.accessible_text(80),
"Bar chart, 4 bars in 2 groups, values from 2 to 4, 1 without a value\n\
Q1 a 4, Q1 b 2, Q2 a 3, Q2 b no value"
);
assert_eq!(BarChart::new().accessible_text(80), "Bar chart, no bars");
let latencies = [12.0, 14.0, 15.0, 21.0, 22.0, 23.0, 24.0, 38.0, f64::NAN];
let histogram = Histogram::new(latencies).bins(3).range(10.0, 40.0);
assert_eq!(
histogram.accessible_text(80),
"Histogram, 8 values in 3 bins from 10 to 40, 1 ignored\n\
[10, 20) 3, [20, 30) 4, [30, 40] 1"
);
assert_eq!(
Histogram::new([f64::NAN]).accessible_text(80),
"Histogram, 0 values, 1 ignored"
);
}
fn rows(width: usize, chart: &BarChart) -> Vec<String> {
let console = Console::builder().width(width).color_system(None).build();
console
.render_export(chart)
.lines()
.map(str::to_string)
.collect()
}
fn coloured(width: usize) -> Console {
Console::builder()
.width(width)
.force_terminal(true)
.color_system(Some(rich::ColorSystem::Truecolor))
.no_color(false)
.build()
}
fn style_at(line: &[Segment], col: usize) -> Option<Style> {
let mut seen = 0;
for segment in line {
let n = cells(&segment.text);
if col < seen + n {
return segment.style.clone();
}
seen += n;
}
None
}
#[test]
fn grouped_vertical_bars_have_group_labels() {
let chart = BarChart::new()
.group(
BarGroup::new("Q1")
.bar(Bar::new("a", 2.0))
.bar(Bar::new("b", 4.0)),
)
.group(
BarGroup::new("Q2")
.bar(Bar::new("a", 3.0))
.bar(Bar::new("b", 1.0)),
)
.orientation(Orientation::Vertical)
.bar_width(2);
assert_eq!(
rows(40, &chart),
vec![" 4 3 ", "2 █ ▄ 1", "█ █ █ ▄", "a b a b", "Q1 Q2 "]
);
let mixed = BarChart::new()
.bar("x", 1.0)
.group(BarGroup::new("g").bar(Bar::new("y", 1.0)))
.bar("z", 1.0)
.orientation(Orientation::Vertical)
.show_values(false)
.max(1.0)
.bar_width(1);
assert_eq!(rows(40, &mixed), vec!["█ █ █", "x y z", " g "]);
let empty = BarChart::new().group(BarGroup::new("none"));
assert!(empty.bars().is_empty());
assert_eq!(rows(10, &empty), vec!["no data"]);
}
#[test]
fn values_inside_horizontal_bars_as_demo2_draws_the_seasons() {
let seasons = BarChart::new()
.push(Bar::new("", 51.0).text("Winter 37-51"))
.push(Bar::new("", 65.0).text("Spring 40-65"))
.push(Bar::new("", 77.0).text("Summer 54-77"))
.push(Bar::new("", 71.0).text("Fall 41-71").value_style("bold"))
.bar_gap(1)
.style("#203060")
.value_style("grey70 on #203060")
.values_inside(true)
.bar_width(20);
assert_eq!(
rows(40, &seasons),
vec![
"Winter 37-51█▎ ",
" ",
"Spring 40-65████▉ ",
" ",
"Summer 54-77████████",
" ",
"Fall 41-71████████▌ ",
]
);
let console = coloured(40);
let lines = console.render_lines(&seasons, &console.options(), false);
assert_eq!(
style_at(&lines[0], 0),
Style::parse("grey70 on #203060").ok()
);
assert_eq!(style_at(&lines[0], 12), Style::parse("#203060").ok());
assert_eq!(
style_at(&lines[6], 0),
Style::parse("grey70 on #203060 bold").ok()
);
let short = BarChart::new()
.push(Bar::new("", 1.0).text("long text"))
.push(Bar::new("", 10.0))
.style("green")
.values_inside(true)
.bar_width(10);
let lines = console.render_lines(&short, &console.options(), false);
let text: String = lines[0].iter().map(|s| s.text.as_str()).collect();
assert_eq!(text, "long text ");
assert_eq!(style_at(&lines[0], 0), Style::parse("green reverse").ok());
assert_eq!(style_at(&lines[0], 4), Style::parse("green").ok());
}
#[test]
fn text_values_wider_than_a_bar_are_centred_and_cut() {
let chart = BarChart::new()
.push(Bar::new("a", 4.0).text("1234"))
.push(Bar::new("b", 4.0).text("7"))
.orientation(Orientation::Vertical)
.thickness(3)
.values_inside(true)
.max(4.0)
.bar_width(2);
assert_eq!(rows(40, &chart), vec!["███ ███", "12… █7█", " a b "]);
let thin = chart.clone().thickness(1);
assert_eq!(rows(40, &thin), vec!["█ █", "█ 7", "a b"]);
let above = chart.thickness(1).values_inside(false);
assert_eq!(
rows(40, &above),
vec!["1234 7 ", " █ █ ", " █ █ ", " a b "]
);
}
#[test]
fn thick_bars() {
let vertical = BarChart::from_pairs([("x", 2.0), ("y", 1.0)])
.orientation(Orientation::Vertical)
.thickness(5)
.bar_width(2);
assert_eq!(
rows(40, &vertical),
vec![" 2 ", "█████ 1 ", "█████ █████", " x y "]
);
let horizontal = BarChart::new()
.bar("a", 2.0)
.bar("b", 1.0)
.thickness(5)
.bar_width(4);
assert_eq!(
rows(40, &horizontal),
vec![
" ████ ",
" ████ ",
"a ████ 2",
" ████ ",
" ████ ",
" ██ ",
" ██ ",
"b ██ 1",
" ██ ",
" ██ ",
]
);
}
#[test]
fn gaps_between_bars_and_groups() {
let group = |a: &str, b: &str| BarGroup::new("").bars([Bar::new(a, 1.0), Bar::new(b, 1.0)]);
let vertical = BarChart::new()
.group(group("a", "b"))
.group(group("c", "d"))
.orientation(Orientation::Vertical)
.bar_gap(2)
.group_gap(3)
.show_values(false)
.max(1.0)
.bar_width(1);
assert_eq!(rows(40, &vertical), vec!["█ █ █ █", "a b c d"]);
let horizontal = BarChart::new()
.group(
BarGroup::new("one")
.bar(Bar::new("a", 1.0))
.bar(Bar::new("b", 2.0)),
)
.group(BarGroup::new("two").bar(Bar::new("c", 3.0)))
.bar_gap(1)
.group_gap(1)
.show_values(false)
.bar_width(3);
assert_eq!(
rows(40, &horizontal),
vec![
"a █ ",
" ",
"b ██ ",
" one",
" ",
"c ███",
" two"
]
);
let default = horizontal.clone();
let default = BarChart {
bar_gap: None,
group_gap: None,
..default
};
assert_eq!(
rows(40, &default),
vec!["a █ ", "b ██ ", " one", " ", "c ███", " two"]
);
}
#[test]
fn labels_and_values_take_the_chart_and_bar_styles() {
let chart = BarChart::new()
.push(Bar::new("Mon", 76.0).text("76°").value_style("bold"))
.push(Bar::new("Tue", 65.0).label_style("underline"))
.label_style("yellow")
.value_style("italic")
.bar_width(10);
let console = coloured(40);
let lines = console.render_lines(&chart, &console.options(), false);
assert_eq!(style_at(&lines[0], 0), Style::parse("yellow").ok());
assert_eq!(
style_at(&lines[1], 0),
Style::parse("yellow underline").ok()
);
let value_at = |line: &[Segment], text: &str| {
line.iter()
.find(|s| s.text.contains(text))
.and_then(|s| s.style.clone())
};
assert_eq!(value_at(&lines[0], "76°"), Style::parse("italic bold").ok());
assert_eq!(value_at(&lines[1], "65"), Style::parse("italic").ok());
assert_eq!(rows(40, &chart)[0], "Mon ██████████ 76°");
let ascii = Console::builder()
.width(40)
.color_system(None)
.ascii_only(true)
.build();
assert_eq!(
ascii.render_export(&chart).lines().next(),
Some("Mon ########## 76?")
);
}
#[test]
fn vertical_bars_fill_the_height_they_are_given() {
let chart = BarChart::from_pairs([("mon", 4.0), ("tue", 7.5), ("wed", -2.0)])
.orientation(Orientation::Vertical);
let console = Console::builder().width(40).color_system(None).build();
let lines = console.render_lines(&chart, &console.options().update_height(7), false);
let text: Vec<String> = lines
.iter()
.map(|l| l.iter().map(|s| s.text.as_str()).collect())
.collect();
assert_eq!(
text,
vec![
" 7.5 ",
" 4 ███ ",
"▅▅▅ ███ ",
"███ ███ ",
" ███",
" -2 ",
"mon tue wed",
]
);
let fixed = chart.clone().bar_width(2);
let lines = console.render_lines(&fixed, &console.options().update_height(7), false);
assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 5);
assert_eq!(console.render_export(&chart).lines().count(), 11);
let horizontal = BarChart::from_pairs([("a", 1.0)]);
let lines = console.render_lines(&horizontal, &console.options().update_height(7), false);
assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 1);
}
}