Skip to main content

rich_ext/chart/
pie.rs

1//! Pie and donut charts: shares of a whole as slices of a disc, with a
2//! legend of labels and percentages.
3
4use 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
14/// Rows for the disc when neither [`PieChart::height`] nor the height it is
15/// given says otherwise.
16const DEFAULT_ROWS: usize = 8;
17/// The tallest disc drawn, in rows.
18const MAX_ROWS: usize = 512;
19/// The largest share of the radius a donut's hole takes.
20const MAX_HOLE: f64 = 0.9;
21/// Cell fills that tell slices apart without colour, by slice (they cycle).
22const PATTERNS: [char; 6] = ['█', '░', '▓', '▒', '▚', '▞'];
23/// The same in ASCII.
24const ASCII_PATTERNS: [char; 8] = ['#', '+', '@', ':', '%', '=', 'o', '*'];
25/// The legend's marker when colour tells the slices apart.
26const MARKER: char = '■';
27
28/// One slice of a [`PieChart`].
29#[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    /// Its name in the legend.
33    pub label: String,
34    /// Its share of the whole, against the other slices' values. A value
35    /// that is not a finite number above zero takes no part of the disc.
36    pub value: f64,
37    /// A theme key or style definition, instead of `chart.series.N`.
38    pub style: Option<String>,
39}
40
41impl Slice {
42    /// A slice called `label` of `value`.
43    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    /// This slice's style: a theme key or a definition.
52    pub fn style(mut self, style: impl Into<String>) -> Self {
53        self.style = Some(style.into());
54        self
55    }
56}
57
58/// Shares of a whole as slices of a disc, clockwise from the top, with a
59/// legend naming each slice and its percentage; with
60/// [`donut`](Self::donut), a ring with a hole, and text in the hole.
61///
62/// - Slice `N` takes the theme style `chart.series.N` (they cycle), or its
63///   own [`Slice::style`].
64/// - [`Charset::Blocks`] (the default) draws with half blocks (`▀▄█`): two
65///   pixels a cell, each slice's colour, so two slices can share a cell.
66///   Without colour it draws a cell a slice, each slice in its own fill
67///   (`█░▓▒▚▞`), and the legend shows the fills.
68/// - [`Charset::Braille`] draws 2×4 dots a cell and leaves the dots on the
69///   edges between slices out, so the slices read as wedges without colour
70///   too; a cell takes the colour of the slice with most of its dots.
71/// - [`Charset::Ascii`] draws a cell a slice in `#+@:%=o*`, and is what an
72///   ASCII-only console gets whatever was asked for.
73/// - The disc is [`height`](Self::height) rows tall (default: the height it
74///   is given, or 8) and twice that wide, so it looks round. The legend goes
75///   beside it when there is room, under it otherwise; given less width than
76///   the disc, the disc shrinks.
77/// - A value that is not a finite number above zero takes no part of the
78///   disc; the legend writes `-` for its percentage. A slice too small for
79///   a cell is still in the legend.
80///
81/// ```
82/// use rich::Console;
83/// use rich_ext::chart::{Charset, PieChart};
84///
85/// let console = Console::builder().width(40).color_system(None).build();
86/// let pie = PieChart::from_pairs([("api", 50.0), ("web", 30.0), ("cron", 20.0)])
87///     .height(4)
88///     .charset(Charset::Ascii);
89/// assert_eq!(
90///     console.render_export(&pie),
91///     concat!(
92///         " @@@###   # api   50.0%\n",
93///         "+@@@####  + web   30.0%\n",
94///         "++++####  @ cron  20.0%\n",
95///         " +++###                \n",
96///     )
97/// );
98/// ```
99#[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/// Which part of the chart a pixel of the disc is.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121enum Pixel {
122    Empty,
123    Slice(usize),
124}
125
126impl PieChart {
127    /// An empty chart; add slices with [`slice`](Self::slice).
128    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    /// A chart of `(label, value)` pairs.
143    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    /// Add a slice.
152    pub fn slice(self, label: impl Into<String>, value: f64) -> Self {
153        self.push(Slice::new(label, value))
154    }
155
156    /// Add a [`Slice`] built with its own style.
157    pub fn push(mut self, slice: Slice) -> Self {
158        self.slices.push(slice);
159        self
160    }
161
162    /// The slices, in order (clockwise from the top).
163    pub fn slices(&self) -> &[Slice] {
164        &self.slices
165    }
166
167    /// Draw a ring: `hole` is the hole's share of the radius (0 for a pie,
168    /// at most 0.9; 0.5 is a common donut).
169    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    /// Text written in a donut's hole, such as the total, when it fits.
179    pub fn centre_text(mut self, text: impl Into<String>) -> Self {
180        self.centre = Some(text.into());
181        self
182    }
183
184    /// Rows for the disc (at least 1); it is twice as many cells wide.
185    pub fn height(mut self, rows: usize) -> Self {
186        self.rows = Some(rows.clamp(1, MAX_ROWS));
187        self
188    }
189
190    /// Glyphs to draw with (default [`Charset::Auto`]: half blocks).
191    pub fn charset(mut self, charset: Charset) -> Self {
192        self.charset = charset;
193        self
194    }
195
196    /// Show the legend (default on).
197    pub fn legend(mut self, legend: bool) -> Self {
198        self.legend = legend;
199        self
200    }
201
202    /// Write each slice's percentage in the legend (default on).
203    pub fn show_percentages(mut self, show: bool) -> Self {
204        self.percentages = show;
205        self
206    }
207
208    /// Write each slice's value in the legend (default off).
209    pub fn show_values(mut self, show: bool) -> Self {
210        self.values = show;
211        self
212    }
213
214    /// How values are written (default [`ValueFormat::Compact`]).
215    pub fn format(mut self, format: ValueFormat) -> Self {
216        self.format = format;
217        self
218    }
219
220    /// Whether a slice takes part in the disc.
221    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    /// Each slice's share of the whole (0 to 1), `None` for one that takes
234    /// no part.
235    pub fn shares(&self) -> Vec<Option<f64>> {
236        // Summed over the largest value, so finite values too large to add
237        // up (two of `f64::MAX`) still share the disc.
238        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    /// Where each slice ends, as a share of the turn, for the slices that
258    /// take part; the others end where the one before them does.
259    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    /// The pixel at `(u, v)`, both from -1 to 1 with `v` down, and how far
279    /// it is from the nearest edge between slices, along the arc.
280    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    /// The disc's rows and the legend's, and whether the legend goes beside
313    /// the disc.
314    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    /// The legend's lines: a marker, the label, the percentage and the
333    /// value, the columns aligned.
334    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    /// What marks slice `index` in the legend.
392    fn marker(&self, charset: Charset, colour: bool, index: usize) -> char {
393        match charset {
394            // The disc is drawn in patterns in ASCII, with colour or not.
395            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    /// The disc, `rows` tall and `2 * rows` wide.
411    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                        // 2×4 dots; a dot within half a dot of an edge
425                        // between slices is left out.
426                        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    /// The centre text over the middle row of the hole, when it fits.
481    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        // Rebuild the row: the disc's cells before and after the text.
503        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            // The legend centred against the disc.
542            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
576/// The bit for dot `(dx, dy)` of a Braille cell.
577fn 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
582/// A cell of two pixels, top and bottom, in half blocks.
583fn 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
635/// `Pie chart, 3 slices, total 100`, then every slice (up to six) with its
636/// value and percentage, or the three largest and the smallest. A donut
637/// says `Donut chart`.
638impl 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        // Just right of the top: the first slice.
711        assert_eq!(pie.pixel(&ends, 0.1, -0.9).0, Pixel::Slice(0));
712        // Lower right: the second.
713        assert_eq!(pie.pixel(&ends, 0.5, 0.5).0, Pixel::Slice(1));
714        // The left half: the third.
715        assert_eq!(pie.pixel(&ends, -0.5, 0.0).0, Pixel::Slice(2));
716        // Outside the disc.
717        assert_eq!(pie.pixel(&ends, 0.9, 0.9).0, Pixel::Empty);
718        // In a donut's hole.
719        let donut = pie.donut(0.5);
720        assert_eq!(donut.pixel(&ends, 0.1, 0.1).0, Pixel::Empty);
721    }
722}