1use std::f64::consts::TAU;
5
6use rich::measure::Measurement;
7use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
8
9use super::{
10 Charset, Line, ValueFormat, cells, has_colour, lines_to_segments, series_key, truncate,
11 user_style,
12};
13
14const DEFAULT_ROWS: usize = 8;
17const MAX_ROWS: usize = 512;
19const MAX_HOLE: f64 = 0.9;
21const PATTERNS: [char; 6] = ['█', '░', '▓', '▒', '▚', '▞'];
23const ASCII_PATTERNS: [char; 8] = ['#', '+', '@', ':', '%', '=', 'o', '*'];
25const MARKER: char = '■';
27
28#[derive(Clone, Debug, PartialEq)]
30#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
31pub struct Slice {
32 pub label: String,
34 pub value: f64,
37 pub style: Option<String>,
39}
40
41impl Slice {
42 pub fn new(label: impl Into<String>, value: f64) -> Self {
44 Slice {
45 label: label.into(),
46 value,
47 style: None,
48 }
49 }
50
51 pub fn style(mut self, style: impl Into<String>) -> Self {
53 self.style = Some(style.into());
54 self
55 }
56}
57
58#[derive(Clone, Debug, PartialEq)]
100#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
101pub struct PieChart {
102 slices: Vec<Slice>,
103 hole: f64,
104 rows: Option<usize>,
105 charset: Charset,
106 legend: bool,
107 percentages: bool,
108 values: bool,
109 format: ValueFormat,
110 centre: Option<String>,
111}
112
113impl Default for PieChart {
114 fn default() -> Self {
115 Self::new()
116 }
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121enum Pixel {
122 Empty,
123 Slice(usize),
124}
125
126impl PieChart {
127 pub fn new() -> Self {
129 PieChart {
130 slices: Vec::new(),
131 hole: 0.0,
132 rows: None,
133 charset: Charset::Auto,
134 legend: true,
135 percentages: true,
136 values: false,
137 format: ValueFormat::Compact,
138 centre: None,
139 }
140 }
141
142 pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
144 pairs
145 .into_iter()
146 .fold(Self::new(), |chart, (label, value)| {
147 chart.slice(label, value)
148 })
149 }
150
151 pub fn slice(self, label: impl Into<String>, value: f64) -> Self {
153 self.push(Slice::new(label, value))
154 }
155
156 pub fn push(mut self, slice: Slice) -> Self {
158 self.slices.push(slice);
159 self
160 }
161
162 pub fn slices(&self) -> &[Slice] {
164 &self.slices
165 }
166
167 pub fn donut(mut self, hole: f64) -> Self {
170 self.hole = if hole.is_finite() {
171 hole.clamp(0.0, MAX_HOLE)
172 } else {
173 0.0
174 };
175 self
176 }
177
178 pub fn centre_text(mut self, text: impl Into<String>) -> Self {
180 self.centre = Some(text.into());
181 self
182 }
183
184 pub fn height(mut self, rows: usize) -> Self {
186 self.rows = Some(rows.clamp(1, MAX_ROWS));
187 self
188 }
189
190 pub fn charset(mut self, charset: Charset) -> Self {
192 self.charset = charset;
193 self
194 }
195
196 pub fn legend(mut self, legend: bool) -> Self {
198 self.legend = legend;
199 self
200 }
201
202 pub fn show_percentages(mut self, show: bool) -> Self {
204 self.percentages = show;
205 self
206 }
207
208 pub fn show_values(mut self, show: bool) -> Self {
210 self.values = show;
211 self
212 }
213
214 pub fn format(mut self, format: ValueFormat) -> Self {
216 self.format = format;
217 self
218 }
219
220 fn counts(value: f64) -> bool {
222 value.is_finite() && value > 0.0
223 }
224
225 fn total(&self) -> f64 {
226 self.slices
227 .iter()
228 .map(|s| s.value)
229 .filter(|v| Self::counts(*v))
230 .sum()
231 }
232
233 pub fn shares(&self) -> Vec<Option<f64>> {
236 let largest = self
239 .slices
240 .iter()
241 .map(|s| s.value)
242 .filter(|v| Self::counts(*v))
243 .fold(0.0, f64::max);
244 let scaled: f64 = self
245 .slices
246 .iter()
247 .map(|s| s.value)
248 .filter(|v| Self::counts(*v))
249 .map(|v| v / largest)
250 .sum();
251 self.slices
252 .iter()
253 .map(|s| (Self::counts(s.value) && largest > 0.0).then(|| s.value / largest / scaled))
254 .collect()
255 }
256
257 fn ends(&self) -> Vec<f64> {
260 let mut at = 0.0;
261 self.shares()
262 .into_iter()
263 .map(|share| {
264 at += share.unwrap_or(0.0);
265 at
266 })
267 .collect()
268 }
269
270 fn style_of(&self, console: &Console, index: usize) -> Style {
271 let spec = self.slices[index]
272 .style
273 .clone()
274 .unwrap_or_else(|| series_key(index));
275 user_style(console, &spec)
276 }
277
278 fn pixel(&self, ends: &[f64], u: f64, v: f64) -> (Pixel, f64) {
281 let r = (u * u + v * v).sqrt();
282 if r > 1.0 || r < self.hole {
283 return (Pixel::Empty, f64::INFINITY);
284 }
285 let turn = (u.atan2(-v) / TAU).rem_euclid(1.0);
286 let shares = self.shares();
287 let index = ends
288 .iter()
289 .enumerate()
290 .position(|(i, &end)| shares[i].is_some() && turn < end)
291 .or_else(|| shares.iter().rposition(Option::is_some));
292 let Some(index) = index else {
293 return (Pixel::Empty, f64::INFINITY);
294 };
295 let several = shares.iter().filter(|s| s.is_some()).count() > 1;
296 let edge = if several {
297 ends.iter()
298 .map(|&end| {
299 let d = (turn - end).abs();
300 d.min(1.0 - d)
301 })
302 .chain(std::iter::once(turn.min(1.0 - turn)))
303 .fold(f64::INFINITY, f64::min)
304 * TAU
305 * r
306 } else {
307 f64::INFINITY
308 };
309 (Pixel::Slice(index), edge)
310 }
311
312 fn fit(&self, width: usize, height: Option<usize>, legend_w: usize) -> (usize, bool) {
315 let legend_rows = if self.legend { self.slices.len() } else { 0 };
316 let mut rows = self
317 .rows
318 .or(height)
319 .unwrap_or(DEFAULT_ROWS)
320 .clamp(1, MAX_ROWS);
321 let beside = legend_w > 0 && width >= rows * 2 + 2 + legend_w;
322 if !beside
323 && self.rows.is_none()
324 && let Some(h) = height
325 {
326 rows = h.saturating_sub(legend_rows).max(1).min(rows);
327 }
328 rows = rows.min((width / 2).max(1));
329 (rows, beside)
330 }
331
332 fn legend_lines(&self, console: &Console, charset: Charset, colour: bool) -> Vec<Line> {
335 if !self.legend {
336 return Vec::new();
337 }
338 let ascii = charset == Charset::Ascii;
339 let shares = self.shares();
340 let percent = |share: Option<f64>| match share {
341 Some(s) => format!("{:.1}%", s * 100.0),
342 None => "-".to_string(),
343 };
344 let label_w = self
345 .slices
346 .iter()
347 .map(|s| cells(&truncate(&s.label, usize::MAX, ascii)))
348 .max()
349 .unwrap_or(0);
350 let pct_w = shares
351 .iter()
352 .map(|s| cells(&percent(*s)))
353 .max()
354 .unwrap_or(0);
355 let values: Vec<String> = self
356 .slices
357 .iter()
358 .map(|s| truncate(&self.format.format(s.value), usize::MAX, ascii))
359 .collect();
360 let value_w = values.iter().map(|v| cells(v)).max().unwrap_or(0);
361 let label_style = colour.then(|| super::theme_style(console, "chart.label"));
362 let value_style = colour.then(|| super::theme_style(console, "chart.value"));
363 self.slices
364 .iter()
365 .enumerate()
366 .map(|(i, slice)| {
367 let mut line = Line::new();
368 let marker = self.marker(charset, colour, i);
369 line.push(
370 &marker.to_string(),
371 colour.then(|| self.style_of(console, i)),
372 );
373 line.pad(1);
374 let label = truncate(&slice.label, usize::MAX, ascii);
375 line.push(&label, label_style.clone());
376 line.pad(label_w - cells(&label));
377 if self.percentages {
378 let text = percent(shares[i]);
379 line.pad(2 + pct_w - cells(&text));
380 line.push(&text, value_style.clone());
381 }
382 if self.values {
383 line.pad(2 + value_w - cells(&values[i]));
384 line.push(&values[i], value_style.clone());
385 }
386 line
387 })
388 .collect()
389 }
390
391 fn marker(&self, charset: Charset, colour: bool, index: usize) -> char {
393 match charset {
394 Charset::Ascii => ASCII_PATTERNS[index % ASCII_PATTERNS.len()],
396 _ if colour => MARKER,
397 Charset::Braille => '⣿',
398 _ => PATTERNS[index % PATTERNS.len()],
399 }
400 }
401
402 fn legend_width(&self, console: &Console, charset: Charset) -> usize {
403 self.legend_lines(console, charset, false)
404 .iter()
405 .map(Line::width)
406 .max()
407 .unwrap_or(0)
408 }
409
410 fn disc(&self, console: &Console, charset: Charset, colour: bool, rows: usize) -> Vec<Line> {
412 let cols = rows * 2;
413 let ends = self.ends();
414 let styles: Vec<Option<Style>> = (0..self.slices.len())
415 .map(|i| colour.then(|| self.style_of(console, i)))
416 .collect();
417 let at = |n: usize, of: usize| (n as f64 + 0.5) / of as f64 * 2.0 - 1.0;
418 let mut out = Vec::with_capacity(rows);
419 for row in 0..rows {
420 let mut line = Line::new();
421 for col in 0..cols {
422 let (cell, style) = match charset {
423 Charset::Braille => {
424 let gap = 0.5 / cols as f64 * 2.0;
427 let mut bits = 0u8;
428 let mut votes = vec![0usize; self.slices.len()];
429 for dy in 0..4 {
430 for dx in 0..2 {
431 let u = at(col * 2 + dx, cols * 2);
432 let v = at(row * 4 + dy, rows * 4);
433 if let (Pixel::Slice(i), edge) = self.pixel(&ends, u, v)
434 && edge >= gap
435 {
436 bits |= braille_bit(dx, dy);
437 votes[i] += 1;
438 }
439 }
440 }
441 let winner = votes
442 .iter()
443 .enumerate()
444 .filter(|(_, n)| **n > 0)
445 .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(&a.0)))
446 .map(|(i, _)| i);
447 if bits == 0 {
448 (' ', None)
449 } else {
450 let c = char::from_u32(0x2800 + u32::from(bits)).unwrap_or(' ');
451 (c, winner.and_then(|i| styles[i].clone()))
452 }
453 }
454 Charset::Blocks | Charset::Auto if colour => {
455 let u = at(col, cols);
456 let top = self.pixel(&ends, u, at(row * 2, rows * 2)).0;
457 let bottom = self.pixel(&ends, u, at(row * 2 + 1, rows * 2)).0;
458 half_cell(top, bottom, &styles)
459 }
460 _ => {
461 let patterns: &[char] = if charset == Charset::Ascii {
462 &ASCII_PATTERNS
463 } else {
464 &PATTERNS
465 };
466 match self.pixel(&ends, at(col, cols), at(row, rows)).0 {
467 Pixel::Empty => (' ', None),
468 Pixel::Slice(i) => (patterns[i % patterns.len()], styles[i].clone()),
469 }
470 }
471 };
472 line.push(&cell.to_string(), style);
473 }
474 out.push(line);
475 }
476 self.write_centre(&mut out, rows, charset == Charset::Ascii, console, colour);
477 out
478 }
479
480 fn write_centre(
482 &self,
483 lines: &mut [Line],
484 rows: usize,
485 ascii: bool,
486 console: &Console,
487 colour: bool,
488 ) {
489 let Some(text) = &self.centre else {
490 return;
491 };
492 let room = (self.hole * rows as f64 * 2.0).floor() as usize;
493 let text = truncate(text, usize::MAX, ascii);
494 if self.hole <= 0.0 || cells(&text) + 1 > room || text.is_empty() {
495 return;
496 }
497 let row = (rows - 1) / 2;
498 let cols = rows * 2;
499 let start = (cols - cells(&text)) / 2;
500 let mut line = Line::new();
501 let old = std::mem::take(&mut lines[row]);
502 let mut cells_: Vec<(String, Option<Style>)> = Vec::new();
504 for (t, s) in old.spans {
505 for c in t.chars() {
506 cells_.push((c.to_string(), s.clone()));
507 }
508 }
509 let style = colour.then(|| super::theme_style(console, "chart.title"));
510 for (i, (c, s)) in cells_.iter().enumerate() {
511 if i == start {
512 line.push(&text, style.clone());
513 }
514 if i < start || i >= start + cells(&text) {
515 line.push(c, s.clone());
516 }
517 }
518 lines[row] = line;
519 }
520
521 pub(crate) fn render_lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
522 let width = options.max_width;
523 let charset = self.charset.resolve(console, options, Charset::Blocks);
524 let ascii = charset == Charset::Ascii;
525 if self.total() <= 0.0 {
526 let mut line = Line::new();
527 line.push(&truncate("no data", width, ascii), None);
528 return vec![line];
529 }
530 let colour = has_colour(console);
531 let legend_w = if self.legend {
532 self.legend_width(console, charset)
533 } else {
534 0
535 };
536 let (rows, beside) = self.fit(width, options.height, legend_w);
537 let disc = self.disc(console, charset, colour, rows);
538 let legend = self.legend_lines(console, charset, colour);
539 if beside {
540 let n = disc.len().max(legend.len());
541 let top = (disc.len().saturating_sub(legend.len())) / 2;
543 let mut disc = disc.into_iter();
544 let mut legend = legend.into_iter();
545 (0..n)
546 .map(|row| {
547 let mut line = disc.next().unwrap_or_default();
548 line.pad(rows * 2 - line.width());
549 if row >= top
550 && let Some(entry) = legend.next()
551 {
552 line.pad(2);
553 line.append(entry);
554 }
555 line
556 })
557 .collect()
558 } else {
559 let mut out = disc;
560 out.extend(legend);
561 out
562 }
563 }
564
565 fn natural_width(&self, console: &Console, charset: Charset) -> usize {
566 let rows = self.rows.unwrap_or(DEFAULT_ROWS);
567 let legend = if self.legend {
568 self.legend_width(console, charset)
569 } else {
570 0
571 };
572 rows * 2 + if legend > 0 { 2 + legend } else { 0 }
573 }
574}
575
576fn braille_bit(dx: usize, dy: usize) -> u8 {
578 const BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
579 BITS[dy][dx]
580}
581
582fn half_cell(top: Pixel, bottom: Pixel, styles: &[Option<Style>]) -> (char, Option<Style>) {
584 match (top, bottom) {
585 (Pixel::Empty, Pixel::Empty) => (' ', None),
586 (Pixel::Slice(a), Pixel::Slice(b)) if a == b => ('█', styles[a].clone()),
587 (Pixel::Slice(a), Pixel::Empty) => ('▀', styles[a].clone()),
588 (Pixel::Empty, Pixel::Slice(b)) => ('▄', styles[b].clone()),
589 (Pixel::Slice(a), Pixel::Slice(b)) => {
590 let below = styles[b].as_ref().and_then(|s| s.color().cloned());
591 let style = match (&styles[a], below) {
592 (Some(s), Some(bg)) => Some(s.combine(&Style::from_color(None, Some(bg)))),
593 (s, _) => s.clone(),
594 };
595 ('▀', style)
596 }
597 }
598}
599
600impl crate::surface::Draw for PieChart {
601 fn draw(
602 &self,
603 console: &Console,
604 options: &ConsoleOptions,
605 screen: &mut crate::surface::Screen,
606 rect: crate::surface::Rect,
607 ) {
608 super::draw_lines(
609 self.render_lines(console, options),
610 options.max_width,
611 options,
612 screen,
613 rect,
614 );
615 }
616}
617
618impl Renderable for PieChart {
619 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
620 lines_to_segments(self.render_lines(console, options), options.max_width)
621 }
622
623 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
624 if self.total() <= 0.0 {
625 return Measurement::new(7, 7).with_maximum(options.max_width);
626 }
627 let charset = self.charset.resolve(console, options, Charset::Blocks);
628 let max = self.natural_width(console, charset);
629 Measurement::new(2.min(max), max)
630 .with_maximum(options.max_width)
631 .normalize()
632 }
633}
634
635impl crate::a11y::AccessibleText for PieChart {
639 fn accessible_text(&self, _width: usize) -> String {
640 use super::describe::{count, counted, ranked};
641 let what = if self.hole > 0.0 {
642 "Donut chart"
643 } else {
644 "Pie chart"
645 };
646 if self.slices.is_empty() {
647 return format!("{what}, no slices");
648 }
649 let mut head = format!("{what}, {}", counted(self.slices.len(), "slice", "slices"));
650 head.push_str(&format!(", total {}", self.format.format(self.total())));
651 let missing = self
652 .slices
653 .iter()
654 .filter(|s| !Self::counts(s.value))
655 .count();
656 if missing > 0 {
657 head.push_str(&format!(", {} without a share", count(missing)));
658 }
659 let shares = self.shares();
660 let entries: Vec<(String, String, f64)> = self
661 .slices
662 .iter()
663 .zip(&shares)
664 .map(|(slice, share)| {
665 let text = match share {
666 Some(s) => format!("{} ({:.1}%)", self.format.format(slice.value), s * 100.0),
667 None => "no share".to_string(),
668 };
669 let rank = if share.is_some() {
670 slice.value
671 } else {
672 f64::NAN
673 };
674 (slice.label.clone(), text, rank)
675 })
676 .collect();
677 let mut out = vec![head];
678 out.extend(ranked(&entries, 6));
679 out.join("\n")
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 #[test]
688 fn shares_skip_values_that_cannot_be_drawn() {
689 let pie = PieChart::new()
690 .slice("a", 3.0)
691 .slice("b", -1.0)
692 .slice("c", f64::NAN)
693 .slice("d", 1.0);
694 assert_eq!(pie.shares(), vec![Some(0.75), None, None, Some(0.25)]);
695 }
696
697 #[test]
698 fn values_too_large_to_add_up_still_share_the_disc() {
699 let pie = PieChart::new().slice("a", 1e308).slice("b", 1e308);
700 assert_eq!(pie.shares(), vec![Some(0.5), Some(0.5)]);
701 let ends = pie.ends();
702 assert_eq!(pie.pixel(&ends, 0.1, -0.9).0, Pixel::Slice(0));
703 assert_eq!(pie.pixel(&ends, -0.5, 0.0).0, Pixel::Slice(1));
704 }
705
706 #[test]
707 fn the_first_slice_starts_at_the_top_and_runs_clockwise() {
708 let pie = PieChart::from_pairs([("a", 1.0), ("b", 1.0), ("c", 2.0)]);
709 let ends = pie.ends();
710 assert_eq!(pie.pixel(&ends, 0.1, -0.9).0, Pixel::Slice(0));
712 assert_eq!(pie.pixel(&ends, 0.5, 0.5).0, Pixel::Slice(1));
714 assert_eq!(pie.pixel(&ends, -0.5, 0.0).0, Pixel::Slice(2));
716 assert_eq!(pie.pixel(&ends, 0.9, 0.9).0, Pixel::Empty);
718 let donut = pie.donut(0.5);
720 assert_eq!(donut.pixel(&ends, 0.1, 0.1).0, Pixel::Empty);
721 }
722}