Skip to main content

gpui_component/chart/
pie_chart.rs

1use std::rc::Rc;
2
3use gpui::{App, Bounds, Hsla, Pixels, SharedString, TextAlign, Window, point};
4use gpui_component_macros::IntoPlot;
5use num_traits::Zero;
6
7use crate::{
8    ActiveTheme,
9    plot::{
10        Plot,
11        label::{PlotLabel, TEXT_HEIGHT, TEXT_SIZE, Text},
12        polygon,
13        shape::{Arc, ArcData, Pie},
14    },
15};
16
17/// The default extra gap (in pixels) between `outer_radius` and the label radius.
18const DEFAULT_LABEL_GAP: f32 = 15.;
19
20#[derive(IntoPlot)]
21pub struct PieChart<T: 'static> {
22    data: Vec<T>,
23    inner_radius: f32,
24    inner_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
25    outer_radius: f32,
26    outer_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
27    pad_angle: f32,
28    value: Option<Rc<dyn Fn(&T) -> f32>>,
29    color: Option<Rc<dyn Fn(&T) -> Hsla>>,
30    label: Option<Rc<dyn Fn(&T) -> SharedString + 'static>>,
31    label_line_color: Option<Rc<dyn Fn(&T) -> Hsla + 'static>>,
32    label_color: Option<Hsla>,
33    label_gap: f32,
34}
35
36impl<T> PieChart<T> {
37    pub fn new<I>(data: I) -> Self
38    where
39        I: IntoIterator<Item = T>,
40    {
41        Self {
42            data: data.into_iter().collect(),
43            inner_radius: 0.,
44            inner_radius_fn: None,
45            outer_radius: 0.,
46            outer_radius_fn: None,
47            pad_angle: 0.,
48            value: None,
49            color: None,
50            label: None,
51            label_line_color: None,
52            label_color: None,
53            label_gap: DEFAULT_LABEL_GAP,
54        }
55    }
56
57    /// Set the inner radius of the pie chart.
58    pub fn inner_radius(mut self, inner_radius: f32) -> Self {
59        self.inner_radius = inner_radius;
60        self
61    }
62
63    /// Set the inner radius of the pie chart based on the arc data.
64    pub fn inner_radius_fn(
65        mut self,
66        inner_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
67    ) -> Self {
68        self.inner_radius_fn = Some(Rc::new(inner_radius_fn));
69        self
70    }
71
72    fn get_inner_radius(&self, arc: &ArcData<T>) -> f32 {
73        if let Some(inner_radius_fn) = self.inner_radius_fn.as_ref() {
74            inner_radius_fn(arc)
75        } else {
76            self.inner_radius
77        }
78    }
79
80    /// Set the outer radius of the pie chart.
81    pub fn outer_radius(mut self, outer_radius: f32) -> Self {
82        self.outer_radius = outer_radius;
83        self
84    }
85
86    /// Set the outer radius of the pie chart based on the arc data.
87    pub fn outer_radius_fn(
88        mut self,
89        outer_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
90    ) -> Self {
91        self.outer_radius_fn = Some(Rc::new(outer_radius_fn));
92        self
93    }
94
95    fn get_outer_radius(&self, arc: &ArcData<T>) -> f32 {
96        if let Some(outer_radius_fn) = self.outer_radius_fn.as_ref() {
97            outer_radius_fn(arc)
98        } else {
99            self.outer_radius
100        }
101    }
102
103    /// Set the pad angle of the pie chart.
104    pub fn pad_angle(mut self, pad_angle: f32) -> Self {
105        self.pad_angle = pad_angle;
106        self
107    }
108
109    pub fn value(mut self, value: impl Fn(&T) -> f32 + 'static) -> Self {
110        self.value = Some(Rc::new(value));
111        self
112    }
113
114    /// Set the color of the pie chart.
115    pub fn color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
116    where
117        H: Into<Hsla> + 'static,
118    {
119        self.color = Some(Rc::new(move |t| color(t).into()));
120        self
121    }
122
123    /// Set the label text for each slice.
124    ///
125    /// Once set, a "leader line + text" is drawn outside the ring for every
126    /// slice.
127    pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
128        self.label = Some(Rc::new(label));
129        self
130    }
131
132    /// Set the leader line color per slice (defaults to `cx.theme().border`).
133    pub fn label_line_color(mut self, color: impl Fn(&T) -> Hsla + 'static) -> Self {
134        self.label_line_color = Some(Rc::new(color));
135        self
136    }
137
138    /// Set the label text color (defaults to `cx.theme().foreground`).
139    pub fn label_color(mut self, color: Hsla) -> Self {
140        self.label_color = Some(color);
141        self
142    }
143
144    /// Set the extra gap between `outer_radius` and the label radius
145    /// (defaults to 15px).
146    pub fn label_gap(mut self, gap: f32) -> Self {
147        self.label_gap = gap;
148        self
149    }
150}
151
152impl<T> Plot for PieChart<T> {
153    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
154        let Some(value_fn) = self.value.as_ref() else {
155            return;
156        };
157
158        let outer_radius = if self.outer_radius.is_zero() {
159            bounds.size.height.as_f32() * 0.4
160        } else {
161            self.outer_radius
162        };
163
164        let arc = Arc::new()
165            .inner_radius(self.inner_radius)
166            .outer_radius(outer_radius);
167        let value_fn = value_fn.clone();
168        let mut pie = Pie::<T>::new().value(move |d| Some(value_fn(d)));
169        pie = pie.pad_angle(self.pad_angle);
170        let arcs = pie.arcs(&self.data);
171
172        for a in &arcs {
173            let inner_radius = self.get_inner_radius(a);
174            let outer_radius = self.get_outer_radius(a);
175            arc.paint(
176                a,
177                if let Some(color_fn) = self.color.as_ref() {
178                    color_fn(a.data)
179                } else {
180                    cx.theme().chart_2
181                },
182                Some(inner_radius),
183                Some(outer_radius),
184                &bounds,
185                window,
186            );
187        }
188
189        // Draw leader-line labels outside the ring (only when `label` is set).
190        let Some(label_fn) = self.label.as_ref() else {
191            return;
192        };
193
194        let label_radius = outer_radius + self.label_gap;
195        let center_x = bounds.size.width.as_f32() / 2.;
196        let center_y = bounds.size.height.as_f32() / 2.;
197        let label_arc = Arc::new()
198            .inner_radius(label_radius)
199            .outer_radius(label_radius);
200        let edge_arc = Arc::new()
201            .inner_radius(outer_radius)
202            .outer_radius(outer_radius);
203
204        let label_color = self.label_color.unwrap_or(cx.theme().foreground);
205        let default_line_color = cx.theme().border;
206
207        // First pass: collect a layout candidate per visible slice, split by
208        // side. `y` is the target vertical position relative to the center and
209        // gets adjusted later to remove overlaps.
210        let mut right: Vec<LabelLayout> = vec![];
211        let mut left: Vec<LabelLayout> = vec![];
212        for a in &arcs {
213            // Skip tiny slices (< 0.5°) that are too thin to label.
214            if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
215                continue;
216            }
217
218            let centroid = label_arc.centroid(a);
219            let edge = edge_arc.centroid(a);
220            let is_right = centroid.x > 0.;
221            let line_color = self
222                .label_line_color
223                .as_ref()
224                .map(|f| f(a.data))
225                .unwrap_or(default_line_color);
226
227            let layout = LabelLayout {
228                arc_x: edge.x,
229                arc_y: edge.y,
230                label_x: centroid.x,
231                y: centroid.y,
232                text: label_fn(a.data),
233                line_color,
234            };
235            if is_right { &mut right } else { &mut left }.push(layout);
236        }
237
238        // Second pass: spread labels on each side so neighbors keep at least one
239        // text height apart, clamped within the vertical bounds.
240        let top = -center_y + TEXT_HEIGHT / 2.;
241        let bottom = center_y - TEXT_HEIGHT / 2.;
242        spread_labels(&mut right, top, bottom);
243        spread_labels(&mut left, top, bottom);
244
245        // Third pass: paint leader lines first, then the text on top.
246        let mut labels = vec![];
247        for (side, items) in [(1., &right), (-1., &left)] {
248            for item in items {
249                // Leader line: ring edge -> label anchor -> horizontal pull to
250                // ±label_radius.
251                let pts = [
252                    point(item.arc_x + center_x, item.arc_y + center_y),
253                    point(item.label_x + center_x, item.y + center_y),
254                    point(side * label_radius + center_x, item.y + center_y),
255                ];
256                if let Some(p) = polygon(&pts, &bounds) {
257                    window.paint_path(p, item.line_color);
258                }
259
260                // Text sits 4px further out, aligned by side.
261                let origin = point(
262                    side * (label_radius + 4.) + center_x,
263                    item.y - TEXT_SIZE / 2. + center_y,
264                );
265                let align = if side > 0. {
266                    TextAlign::Left
267                } else {
268                    TextAlign::Right
269                };
270                labels.push(Text::new(item.text.clone(), origin, label_color).align(align));
271            }
272        }
273
274        PlotLabel::new(labels).paint(&bounds, window, cx);
275    }
276}
277
278/// A resolved label position before overlap adjustment.
279struct LabelLayout {
280    /// Anchor on the ring edge (relative to center).
281    arc_x: f32,
282    arc_y: f32,
283    /// Centroid x at the label radius (relative to center).
284    label_x: f32,
285    /// Target/adjusted vertical position (relative to center).
286    y: f32,
287    text: SharedString,
288    line_color: Hsla,
289}
290
291/// Spread `items` vertically so that adjacent labels keep at least
292/// [`TEXT_HEIGHT`] apart, clamped within `[top, bottom]`.
293///
294/// Uses a two-direction relaxation: a top-down pass pushes crowded labels down,
295/// then a bottom-up pass (anchored at `bottom`) pushes them back up. This
296/// resolves cascading overlaps that a single-neighbor nudge cannot.
297fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
298    let n = items.len();
299    if n == 0 {
300        return;
301    }
302
303    // Sort by target position so neighbors in the slice are neighbors in y.
304    items.sort_by(|a, b| a.y.total_cmp(&b.y));
305
306    // Top-down: enforce the minimum gap by pushing labels down.
307    for i in 1..n {
308        let min_y = items[i - 1].y + TEXT_HEIGHT;
309        if items[i].y < min_y {
310            items[i].y = min_y;
311        }
312    }
313
314    // Bottom-up: clamp the bottom-most label, then pull overflowing labels up.
315    if items[n - 1].y > bottom {
316        items[n - 1].y = bottom;
317    }
318    for i in (0..n - 1).rev() {
319        let max_y = items[i + 1].y - TEXT_HEIGHT;
320        if items[i].y > max_y {
321            items[i].y = max_y;
322        }
323    }
324
325    // Keep the top-most label within bounds.
326    if items[0].y < top {
327        items[0].y = top;
328    }
329}