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