use std::f64::consts::TAU;
use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
use super::{
Charset, Line, ValueFormat, cells, has_colour, lines_to_segments, series_key, truncate,
user_style,
};
const DEFAULT_ROWS: usize = 8;
const MAX_ROWS: usize = 512;
const MAX_HOLE: f64 = 0.9;
const PATTERNS: [char; 6] = ['█', '░', '▓', '▒', '▚', '▞'];
const ASCII_PATTERNS: [char; 8] = ['#', '+', '@', ':', '%', '=', 'o', '*'];
const MARKER: char = '■';
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Slice {
pub label: String,
pub value: f64,
pub style: Option<String>,
}
impl Slice {
pub fn new(label: impl Into<String>, value: f64) -> Self {
Slice {
label: label.into(),
value,
style: None,
}
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct PieChart {
slices: Vec<Slice>,
hole: f64,
rows: Option<usize>,
charset: Charset,
legend: bool,
percentages: bool,
values: bool,
format: ValueFormat,
centre: Option<String>,
}
impl Default for PieChart {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pixel {
Empty,
Slice(usize),
}
impl PieChart {
pub fn new() -> Self {
PieChart {
slices: Vec::new(),
hole: 0.0,
rows: None,
charset: Charset::Auto,
legend: true,
percentages: true,
values: false,
format: ValueFormat::Compact,
centre: None,
}
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
pairs
.into_iter()
.fold(Self::new(), |chart, (label, value)| {
chart.slice(label, value)
})
}
pub fn slice(self, label: impl Into<String>, value: f64) -> Self {
self.push(Slice::new(label, value))
}
pub fn push(mut self, slice: Slice) -> Self {
self.slices.push(slice);
self
}
pub fn slices(&self) -> &[Slice] {
&self.slices
}
pub fn donut(mut self, hole: f64) -> Self {
self.hole = if hole.is_finite() {
hole.clamp(0.0, MAX_HOLE)
} else {
0.0
};
self
}
pub fn centre_text(mut self, text: impl Into<String>) -> Self {
self.centre = Some(text.into());
self
}
pub fn height(mut self, rows: usize) -> Self {
self.rows = Some(rows.clamp(1, MAX_ROWS));
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.charset = charset;
self
}
pub fn legend(mut self, legend: bool) -> Self {
self.legend = legend;
self
}
pub fn show_percentages(mut self, show: bool) -> Self {
self.percentages = show;
self
}
pub fn show_values(mut self, show: bool) -> Self {
self.values = show;
self
}
pub fn format(mut self, format: ValueFormat) -> Self {
self.format = format;
self
}
fn counts(value: f64) -> bool {
value.is_finite() && value > 0.0
}
fn total(&self) -> f64 {
self.slices
.iter()
.map(|s| s.value)
.filter(|v| Self::counts(*v))
.sum()
}
pub fn shares(&self) -> Vec<Option<f64>> {
let largest = self
.slices
.iter()
.map(|s| s.value)
.filter(|v| Self::counts(*v))
.fold(0.0, f64::max);
let scaled: f64 = self
.slices
.iter()
.map(|s| s.value)
.filter(|v| Self::counts(*v))
.map(|v| v / largest)
.sum();
self.slices
.iter()
.map(|s| (Self::counts(s.value) && largest > 0.0).then(|| s.value / largest / scaled))
.collect()
}
fn ends(&self) -> Vec<f64> {
let mut at = 0.0;
self.shares()
.into_iter()
.map(|share| {
at += share.unwrap_or(0.0);
at
})
.collect()
}
fn style_of(&self, console: &Console, index: usize) -> Style {
let spec = self.slices[index]
.style
.clone()
.unwrap_or_else(|| series_key(index));
user_style(console, &spec)
}
fn pixel(&self, ends: &[f64], u: f64, v: f64) -> (Pixel, f64) {
let r = (u * u + v * v).sqrt();
if r > 1.0 || r < self.hole {
return (Pixel::Empty, f64::INFINITY);
}
let turn = (u.atan2(-v) / TAU).rem_euclid(1.0);
let shares = self.shares();
let index = ends
.iter()
.enumerate()
.position(|(i, &end)| shares[i].is_some() && turn < end)
.or_else(|| shares.iter().rposition(Option::is_some));
let Some(index) = index else {
return (Pixel::Empty, f64::INFINITY);
};
let several = shares.iter().filter(|s| s.is_some()).count() > 1;
let edge = if several {
ends.iter()
.map(|&end| {
let d = (turn - end).abs();
d.min(1.0 - d)
})
.chain(std::iter::once(turn.min(1.0 - turn)))
.fold(f64::INFINITY, f64::min)
* TAU
* r
} else {
f64::INFINITY
};
(Pixel::Slice(index), edge)
}
fn fit(&self, width: usize, height: Option<usize>, legend_w: usize) -> (usize, bool) {
let legend_rows = if self.legend { self.slices.len() } else { 0 };
let mut rows = self
.rows
.or(height)
.unwrap_or(DEFAULT_ROWS)
.clamp(1, MAX_ROWS);
let beside = legend_w > 0 && width >= rows * 2 + 2 + legend_w;
if !beside
&& self.rows.is_none()
&& let Some(h) = height
{
rows = h.saturating_sub(legend_rows).max(1).min(rows);
}
rows = rows.min((width / 2).max(1));
(rows, beside)
}
fn legend_lines(&self, console: &Console, charset: Charset, colour: bool) -> Vec<Line> {
if !self.legend {
return Vec::new();
}
let ascii = charset == Charset::Ascii;
let shares = self.shares();
let percent = |share: Option<f64>| match share {
Some(s) => format!("{:.1}%", s * 100.0),
None => "-".to_string(),
};
let label_w = self
.slices
.iter()
.map(|s| cells(&truncate(&s.label, usize::MAX, ascii)))
.max()
.unwrap_or(0);
let pct_w = shares
.iter()
.map(|s| cells(&percent(*s)))
.max()
.unwrap_or(0);
let values: Vec<String> = self
.slices
.iter()
.map(|s| truncate(&self.format.format(s.value), usize::MAX, ascii))
.collect();
let value_w = values.iter().map(|v| cells(v)).max().unwrap_or(0);
let label_style = colour.then(|| super::theme_style(console, "chart.label"));
let value_style = colour.then(|| super::theme_style(console, "chart.value"));
self.slices
.iter()
.enumerate()
.map(|(i, slice)| {
let mut line = Line::new();
let marker = self.marker(charset, colour, i);
line.push(
&marker.to_string(),
colour.then(|| self.style_of(console, i)),
);
line.pad(1);
let label = truncate(&slice.label, usize::MAX, ascii);
line.push(&label, label_style.clone());
line.pad(label_w - cells(&label));
if self.percentages {
let text = percent(shares[i]);
line.pad(2 + pct_w - cells(&text));
line.push(&text, value_style.clone());
}
if self.values {
line.pad(2 + value_w - cells(&values[i]));
line.push(&values[i], value_style.clone());
}
line
})
.collect()
}
fn marker(&self, charset: Charset, colour: bool, index: usize) -> char {
match charset {
Charset::Ascii => ASCII_PATTERNS[index % ASCII_PATTERNS.len()],
_ if colour => MARKER,
Charset::Braille => '⣿',
_ => PATTERNS[index % PATTERNS.len()],
}
}
fn legend_width(&self, console: &Console, charset: Charset) -> usize {
self.legend_lines(console, charset, false)
.iter()
.map(Line::width)
.max()
.unwrap_or(0)
}
fn disc(&self, console: &Console, charset: Charset, colour: bool, rows: usize) -> Vec<Line> {
let cols = rows * 2;
let ends = self.ends();
let styles: Vec<Option<Style>> = (0..self.slices.len())
.map(|i| colour.then(|| self.style_of(console, i)))
.collect();
let at = |n: usize, of: usize| (n as f64 + 0.5) / of as f64 * 2.0 - 1.0;
let mut out = Vec::with_capacity(rows);
for row in 0..rows {
let mut line = Line::new();
for col in 0..cols {
let (cell, style) = match charset {
Charset::Braille => {
let gap = 0.5 / cols as f64 * 2.0;
let mut bits = 0u8;
let mut votes = vec![0usize; self.slices.len()];
for dy in 0..4 {
for dx in 0..2 {
let u = at(col * 2 + dx, cols * 2);
let v = at(row * 4 + dy, rows * 4);
if let (Pixel::Slice(i), edge) = self.pixel(&ends, u, v)
&& edge >= gap
{
bits |= braille_bit(dx, dy);
votes[i] += 1;
}
}
}
let winner = votes
.iter()
.enumerate()
.filter(|(_, n)| **n > 0)
.max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(&a.0)))
.map(|(i, _)| i);
if bits == 0 {
(' ', None)
} else {
let c = char::from_u32(0x2800 + u32::from(bits)).unwrap_or(' ');
(c, winner.and_then(|i| styles[i].clone()))
}
}
Charset::Blocks | Charset::Auto if colour => {
let u = at(col, cols);
let top = self.pixel(&ends, u, at(row * 2, rows * 2)).0;
let bottom = self.pixel(&ends, u, at(row * 2 + 1, rows * 2)).0;
half_cell(top, bottom, &styles)
}
_ => {
let patterns: &[char] = if charset == Charset::Ascii {
&ASCII_PATTERNS
} else {
&PATTERNS
};
match self.pixel(&ends, at(col, cols), at(row, rows)).0 {
Pixel::Empty => (' ', None),
Pixel::Slice(i) => (patterns[i % patterns.len()], styles[i].clone()),
}
}
};
line.push(&cell.to_string(), style);
}
out.push(line);
}
self.write_centre(&mut out, rows, charset == Charset::Ascii, console, colour);
out
}
fn write_centre(
&self,
lines: &mut [Line],
rows: usize,
ascii: bool,
console: &Console,
colour: bool,
) {
let Some(text) = &self.centre else {
return;
};
let room = (self.hole * rows as f64 * 2.0).floor() as usize;
let text = truncate(text, usize::MAX, ascii);
if self.hole <= 0.0 || cells(&text) + 1 > room || text.is_empty() {
return;
}
let row = (rows - 1) / 2;
let cols = rows * 2;
let start = (cols - cells(&text)) / 2;
let mut line = Line::new();
let old = std::mem::take(&mut lines[row]);
let mut cells_: Vec<(String, Option<Style>)> = Vec::new();
for (t, s) in old.spans {
for c in t.chars() {
cells_.push((c.to_string(), s.clone()));
}
}
let style = colour.then(|| super::theme_style(console, "chart.title"));
for (i, (c, s)) in cells_.iter().enumerate() {
if i == start {
line.push(&text, style.clone());
}
if i < start || i >= start + cells(&text) {
line.push(c, s.clone());
}
}
lines[row] = line;
}
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.total() <= 0.0 {
let mut line = Line::new();
line.push(&truncate("no data", width, ascii), None);
return vec![line];
}
let colour = has_colour(console);
let legend_w = if self.legend {
self.legend_width(console, charset)
} else {
0
};
let (rows, beside) = self.fit(width, options.height, legend_w);
let disc = self.disc(console, charset, colour, rows);
let legend = self.legend_lines(console, charset, colour);
if beside {
let n = disc.len().max(legend.len());
let top = (disc.len().saturating_sub(legend.len())) / 2;
let mut disc = disc.into_iter();
let mut legend = legend.into_iter();
(0..n)
.map(|row| {
let mut line = disc.next().unwrap_or_default();
line.pad(rows * 2 - line.width());
if row >= top
&& let Some(entry) = legend.next()
{
line.pad(2);
line.append(entry);
}
line
})
.collect()
} else {
let mut out = disc;
out.extend(legend);
out
}
}
fn natural_width(&self, console: &Console, charset: Charset) -> usize {
let rows = self.rows.unwrap_or(DEFAULT_ROWS);
let legend = if self.legend {
self.legend_width(console, charset)
} else {
0
};
rows * 2 + if legend > 0 { 2 + legend } else { 0 }
}
}
fn braille_bit(dx: usize, dy: usize) -> u8 {
const BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
BITS[dy][dx]
}
fn half_cell(top: Pixel, bottom: Pixel, styles: &[Option<Style>]) -> (char, Option<Style>) {
match (top, bottom) {
(Pixel::Empty, Pixel::Empty) => (' ', None),
(Pixel::Slice(a), Pixel::Slice(b)) if a == b => ('█', styles[a].clone()),
(Pixel::Slice(a), Pixel::Empty) => ('▀', styles[a].clone()),
(Pixel::Empty, Pixel::Slice(b)) => ('▄', styles[b].clone()),
(Pixel::Slice(a), Pixel::Slice(b)) => {
let below = styles[b].as_ref().and_then(|s| s.color().cloned());
let style = match (&styles[a], below) {
(Some(s), Some(bg)) => Some(s.combine(&Style::from_color(None, Some(bg)))),
(s, _) => s.clone(),
};
('▀', style)
}
}
}
impl crate::surface::Draw for PieChart {
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 PieChart {
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.total() <= 0.0 {
return Measurement::new(7, 7).with_maximum(options.max_width);
}
let charset = self.charset.resolve(console, options, Charset::Blocks);
let max = self.natural_width(console, charset);
Measurement::new(2.min(max), max)
.with_maximum(options.max_width)
.normalize()
}
}
impl crate::a11y::AccessibleText for PieChart {
fn accessible_text(&self, _width: usize) -> String {
use super::describe::{count, counted, ranked};
let what = if self.hole > 0.0 {
"Donut chart"
} else {
"Pie chart"
};
if self.slices.is_empty() {
return format!("{what}, no slices");
}
let mut head = format!("{what}, {}", counted(self.slices.len(), "slice", "slices"));
head.push_str(&format!(", total {}", self.format.format(self.total())));
let missing = self
.slices
.iter()
.filter(|s| !Self::counts(s.value))
.count();
if missing > 0 {
head.push_str(&format!(", {} without a share", count(missing)));
}
let shares = self.shares();
let entries: Vec<(String, String, f64)> = self
.slices
.iter()
.zip(&shares)
.map(|(slice, share)| {
let text = match share {
Some(s) => format!("{} ({:.1}%)", self.format.format(slice.value), s * 100.0),
None => "no share".to_string(),
};
let rank = if share.is_some() {
slice.value
} else {
f64::NAN
};
(slice.label.clone(), text, rank)
})
.collect();
let mut out = vec![head];
out.extend(ranked(&entries, 6));
out.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shares_skip_values_that_cannot_be_drawn() {
let pie = PieChart::new()
.slice("a", 3.0)
.slice("b", -1.0)
.slice("c", f64::NAN)
.slice("d", 1.0);
assert_eq!(pie.shares(), vec![Some(0.75), None, None, Some(0.25)]);
}
#[test]
fn values_too_large_to_add_up_still_share_the_disc() {
let pie = PieChart::new().slice("a", 1e308).slice("b", 1e308);
assert_eq!(pie.shares(), vec![Some(0.5), Some(0.5)]);
let ends = pie.ends();
assert_eq!(pie.pixel(&ends, 0.1, -0.9).0, Pixel::Slice(0));
assert_eq!(pie.pixel(&ends, -0.5, 0.0).0, Pixel::Slice(1));
}
#[test]
fn the_first_slice_starts_at_the_top_and_runs_clockwise() {
let pie = PieChart::from_pairs([("a", 1.0), ("b", 1.0), ("c", 2.0)]);
let ends = pie.ends();
assert_eq!(pie.pixel(&ends, 0.1, -0.9).0, Pixel::Slice(0));
assert_eq!(pie.pixel(&ends, 0.5, 0.5).0, Pixel::Slice(1));
assert_eq!(pie.pixel(&ends, -0.5, 0.0).0, Pixel::Slice(2));
assert_eq!(pie.pixel(&ends, 0.9, 0.9).0, Pixel::Empty);
let donut = pie.donut(0.5);
assert_eq!(donut.pixel(&ends, 0.1, 0.1).0, Pixel::Empty);
}
}