Skip to main content

guise/chart/
pie.rs

1//! `PieChart` — proportional filled slices, with an optional donut hole and a
2//! small legend when labels are provided.
3//!
4//! ```ignore
5//! use guise::chart::PieChart;
6//!
7//! PieChart::entries([("Rust", 62.0), ("TOML", 25.0), ("Other", 13.0)]).donut(0.6)
8//! ```
9
10use gpui::prelude::*;
11use gpui::{canvas, div, point, px, App, Hsla, IntoElement, PathBuilder, SharedString, Window};
12
13use crate::style::ColorValue;
14use crate::theme::{theme, Size};
15
16use super::{arc_point, series_color, slice_spans};
17use crate::devtools::Probed;
18
19/// A pie (or donut) chart. Slices are proportional to each value's share of
20/// the total, starting at 12 o'clock and sweeping clockwise. Non-positive
21/// values contribute nothing. Slice colors rotate through the theme palette
22/// by default.
23#[derive(IntoElement)]
24pub struct PieChart {
25    values: Vec<f32>,
26    labels: Vec<SharedString>,
27    colors: Vec<ColorValue>,
28    size: f32,
29    donut: Option<f32>,
30}
31
32impl PieChart {
33    pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
34        PieChart {
35            values: values.into_iter().collect(),
36            labels: Vec::new(),
37            colors: Vec::new(),
38            size: 160.0,
39            donut: None,
40        }
41    }
42
43    /// Build from `(label, value)` pairs; labels render as a legend below.
44    pub fn entries(entries: impl IntoIterator<Item = (impl Into<SharedString>, f32)>) -> Self {
45        let (labels, values): (Vec<SharedString>, Vec<f32>) = entries
46            .into_iter()
47            .map(|(label, value)| (label.into(), value))
48            .unzip();
49        PieChart {
50            labels,
51            ..PieChart::new(values)
52        }
53    }
54
55    /// One color for every slice (disables the palette rotation).
56    pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
57        self.colors = vec![color.into()];
58        self
59    }
60
61    /// Per-slice colors, cycled when shorter than the series.
62    pub fn colors(mut self, colors: impl IntoIterator<Item = impl Into<ColorValue>>) -> Self {
63        self.colors = colors.into_iter().map(Into::into).collect();
64        self
65    }
66
67    /// Diameter in px (default 160). Pies are square.
68    pub fn size(mut self, size: f32) -> Self {
69        self.size = size;
70        self
71    }
72
73    /// Cut a hole in the middle: `inner_fraction` is the hole's share of the
74    /// radius, clamped into `0.05..=0.95`.
75    pub fn donut(mut self, inner_fraction: f32) -> Self {
76        self.donut = Some(inner_fraction.clamp(0.05, 0.95));
77        self
78    }
79}
80
81impl RenderOnce for PieChart {
82    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
83        let t = theme(cx);
84        let n = self.values.len();
85        let slice_colors: Vec<Hsla> = (0..n).map(|i| series_color(t, &self.colors, i)).collect();
86        let legend_colors = slice_colors.clone();
87        let dimmed = t.dimmed().hsla();
88        let font_xs = t.font_size(Size::Xs);
89        let spans = slice_spans(&self.values);
90        let donut = self.donut;
91        let diameter = self.size;
92
93        let plot = canvas(
94            |_, _, _| (),
95            move |bounds, _, window, _cx| {
96                let w = f32::from(bounds.size.width);
97                let h = f32::from(bounds.size.height);
98                let radius = w.min(h) / 2.0;
99                if radius <= 0.0 {
100                    return;
101                }
102                let center = (
103                    f32::from(bounds.origin.x) + w / 2.0,
104                    f32::from(bounds.origin.y) + h / 2.0,
105                );
106                for (i, &(start, end)) in spans.iter().enumerate() {
107                    if end - start <= f32::EPSILON {
108                        continue;
109                    }
110                    let color = slice_colors[i];
111                    let inner = donut.map(|f| radius * f);
112                    if end - start >= 0.999 {
113                        // A (near-)full circle: `arc_to` from a point back to
114                        // itself paints nothing, so draw it as two halves.
115                        let mid = start + 0.5;
116                        paint_slice(window, center, radius, inner, start, mid, color);
117                        paint_slice(window, center, radius, inner, mid, start + 1.0, color);
118                    } else {
119                        paint_slice(window, center, radius, inner, start, end, color);
120                    }
121                }
122            },
123        )
124        .w(px(diameter))
125        .h(px(diameter));
126
127        let mut root = div()
128            .flex()
129            .flex_col()
130            .items_center()
131            .gap(px(8.0))
132            .child(plot);
133
134        // Legend: a wrapping row of color-dot + label pairs. Plain divs.
135        if !self.labels.is_empty() && !legend_colors.is_empty() {
136            let items = self.labels.iter().enumerate().map(|(i, label)| {
137                div()
138                    .flex()
139                    .flex_row()
140                    .items_center()
141                    .gap(px(6.0))
142                    .child(
143                        div()
144                            .w(px(8.0))
145                            .h(px(8.0))
146                            .rounded(px(4.0))
147                            .bg(legend_colors[i % legend_colors.len()]),
148                    )
149                    .child(
150                        div()
151                            .text_size(px(font_xs))
152                            .text_color(dimmed)
153                            .child(label.clone()),
154                    )
155            });
156            root = root.child(
157                div()
158                    .flex()
159                    .flex_row()
160                    .flex_wrap()
161                    .justify_center()
162                    .gap(px(12.0))
163                    .children(items),
164            );
165        }
166        root.probe("PieChart")
167    }
168}
169
170/// Fill one pie/donut slice. `start`/`end` are clockwise fractions of a full
171/// turn from 12 o'clock (`end` may exceed 1.0 when a full circle wraps);
172/// `inner` is the hole radius for donuts. All coordinates window-absolute.
173fn paint_slice(
174    window: &mut Window,
175    center: (f32, f32),
176    radius: f32,
177    inner: Option<f32>,
178    start: f32,
179    end: f32,
180    color: Hsla,
181) {
182    let large = end - start > 0.5;
183    let to_pt = |(x, y): (f32, f32)| point(px(x), px(y));
184    let outer_start = to_pt(arc_point(center, radius, start));
185    let outer_end = to_pt(arc_point(center, radius, end));
186    let radii = point(px(radius), px(radius));
187
188    let mut pb = PathBuilder::fill();
189    match inner {
190        // Donut: outer arc out, straight edge in, inner arc back (an annular
191        // sector traced as one closed contour).
192        Some(hole) if hole > 0.0 => {
193            let inner_radii = point(px(hole), px(hole));
194            pb.move_to(outer_start);
195            pb.arc_to(radii, px(0.0), large, true, outer_end);
196            pb.line_to(to_pt(arc_point(center, hole, end)));
197            pb.arc_to(
198                inner_radii,
199                px(0.0),
200                large,
201                false,
202                to_pt(arc_point(center, hole, start)),
203            );
204            pb.close();
205        }
206        // Pie: center, out to the rim, arc, back to center.
207        _ => {
208            pb.move_to(to_pt(center));
209            pb.line_to(outer_start);
210            pb.arc_to(radii, px(0.0), large, true, outer_end);
211            pb.close();
212        }
213    }
214    if let Ok(path) = pb.build() {
215        window.paint_path(path, color);
216    }
217}