Skip to main content

gpui_component/chart/
pie_chart.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, Bounds, ElementId, Hsla, IntoElement, Pixels, Point, SharedString, TextAlign,
5    Window, point, prelude::FluentBuilder, px,
6};
7use gpui_base::motion::spring;
8use gpui_component_macros::IntoPlot;
9use num_traits::Zero;
10
11use crate::{
12    ActiveTheme,
13    plot::{
14        PathCaches, Plot,
15        label::{PlotLabel, TEXT_HEIGHT, TEXT_SIZE, Text},
16        polygon,
17        shape::{Arc, ArcData, Pie},
18        tooltip::{PlotHover, Tooltip, TooltipState},
19    },
20};
21
22/// The default extra gap (in pixels) between `outer_radius` and the label radius.
23const DEFAULT_LABEL_GAP: f32 = 15.;
24
25/// How far the hovered slice moves out past its outer radius, in pixels.
26const HOVER_LIFT: f32 = 6.;
27
28/// How much the slices other than the hovered one fade, as a share of their opacity.
29const HOVER_DIM: f32 = 0.35;
30
31/// The hover a pie chart paints, sampled once per frame in [`Plot::hover`].
32struct PieHover {
33    /// How far each datum's slice has lifted, `0..=1`, springing up on the
34    /// hovered slice and back down on the one the cursor left.
35    lift: Vec<f32>,
36    /// How far the hover has faded in.
37    focus: f32,
38}
39
40#[derive(IntoPlot)]
41pub struct PieChart<T: 'static> {
42    data: Vec<T>,
43    inner_radius: f32,
44    inner_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
45    outer_radius: f32,
46    outer_radius_fn: Option<Rc<dyn Fn(&ArcData<T>) -> f32 + 'static>>,
47    pad_angle: f32,
48    value: Option<Rc<dyn Fn(&T) -> f32>>,
49    color: Option<Rc<dyn Fn(&T) -> Hsla>>,
50    label: Option<Rc<dyn Fn(&T) -> SharedString + 'static>>,
51    label_line_color: Option<Rc<dyn Fn(&T) -> Hsla + 'static>>,
52    label_color: Option<Hsla>,
53    label_gap: f32,
54    id: Option<ElementId>,
55    name: Option<SharedString>,
56    hover: Option<PieHover>,
57}
58
59impl<T> PieChart<T> {
60    pub fn new<I>(data: I) -> Self
61    where
62        I: IntoIterator<Item = T>,
63    {
64        Self {
65            data: data.into_iter().collect(),
66            inner_radius: 0.,
67            inner_radius_fn: None,
68            outer_radius: 0.,
69            outer_radius_fn: None,
70            pad_angle: 0.,
71            value: None,
72            color: None,
73            label: None,
74            label_line_color: None,
75            label_color: None,
76            label_gap: DEFAULT_LABEL_GAP,
77            id: None,
78            name: None,
79            hover: None,
80        }
81    }
82
83    /// Enable an interactive hover tooltip for this chart: the hovered slice
84    /// lifts out of the ring and the tooltip shows its value and share.
85    ///
86    /// The `id` must be unique among sibling elements. Without it, the chart
87    /// stays a non-interactive plot.
88    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
89        self.id = Some(id.into());
90        self
91    }
92
93    /// Set the series name shown in the hover tooltip row (e.g. "Desktop").
94    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
95        self.name = Some(name.into());
96        self
97    }
98
99    /// Set the inner radius of the pie chart.
100    pub fn inner_radius(mut self, inner_radius: f32) -> Self {
101        self.inner_radius = inner_radius;
102        self
103    }
104
105    /// Set the inner radius of the pie chart based on the arc data.
106    pub fn inner_radius_fn(
107        mut self,
108        inner_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
109    ) -> Self {
110        self.inner_radius_fn = Some(Rc::new(inner_radius_fn));
111        self
112    }
113
114    fn get_inner_radius(&self, arc: &ArcData<T>) -> f32 {
115        if let Some(inner_radius_fn) = self.inner_radius_fn.as_ref() {
116            inner_radius_fn(arc)
117        } else {
118            self.inner_radius
119        }
120    }
121
122    /// Set the outer radius of the pie chart.
123    pub fn outer_radius(mut self, outer_radius: f32) -> Self {
124        self.outer_radius = outer_radius;
125        self
126    }
127
128    /// Set the outer radius of the pie chart based on the arc data.
129    pub fn outer_radius_fn(
130        mut self,
131        outer_radius_fn: impl Fn(&ArcData<T>) -> f32 + 'static,
132    ) -> Self {
133        self.outer_radius_fn = Some(Rc::new(outer_radius_fn));
134        self
135    }
136
137    fn get_outer_radius(&self, arc: &ArcData<T>) -> f32 {
138        if let Some(outer_radius_fn) = self.outer_radius_fn.as_ref() {
139            outer_radius_fn(arc)
140        } else {
141            self.outer_radius
142        }
143    }
144
145    /// Set the pad angle of the pie chart.
146    pub fn pad_angle(mut self, pad_angle: f32) -> Self {
147        self.pad_angle = pad_angle;
148        self
149    }
150
151    pub fn value(mut self, value: impl Fn(&T) -> f32 + 'static) -> Self {
152        self.value = Some(Rc::new(value));
153        self
154    }
155
156    /// Set the color of the pie chart.
157    pub fn color<H>(mut self, color: impl Fn(&T) -> H + 'static) -> Self
158    where
159        H: Into<Hsla> + 'static,
160    {
161        self.color = Some(Rc::new(move |t| color(t).into()));
162        self
163    }
164
165    /// Set the label text for each slice.
166    ///
167    /// Once set, a "leader line + text" is drawn outside the ring for every
168    /// slice.
169    pub fn label(mut self, label: impl Fn(&T) -> SharedString + 'static) -> Self {
170        self.label = Some(Rc::new(label));
171        self
172    }
173
174    /// Set the leader line color per slice (defaults to `cx.theme().border`).
175    pub fn label_line_color(mut self, color: impl Fn(&T) -> Hsla + 'static) -> Self {
176        self.label_line_color = Some(Rc::new(color));
177        self
178    }
179
180    /// Set the label text color (defaults to `cx.theme().foreground`).
181    pub fn label_color(mut self, color: Hsla) -> Self {
182        self.label_color = Some(color);
183        self
184    }
185
186    /// Set the extra gap between `outer_radius` and the label radius
187    /// (defaults to 15px).
188    pub fn label_gap(mut self, gap: f32) -> Self {
189        self.label_gap = gap;
190        self
191    }
192
193    /// The outer radius the ring is laid out with: the set one, or 40% of the
194    /// bounds height.
195    fn resolve_outer_radius(&self, bounds: &Bounds<Pixels>) -> f32 {
196        if self.outer_radius.is_zero() {
197            bounds.size.height.as_f32() * 0.4
198        } else {
199            self.outer_radius
200        }
201    }
202
203    /// The slices, in ring order. Shared by `paint` and `tooltip_state` so the
204    /// two stay in sync; empty without a value accessor.
205    fn arcs(&self) -> Vec<ArcData<'_, T>> {
206        let Some(value_fn) = self.value.clone() else {
207            return vec![];
208        };
209        Pie::<T>::new()
210            .value(move |d| Some(value_fn(d)))
211            .pad_angle(self.pad_angle)
212            .arcs(&self.data)
213    }
214
215    /// The fill of a slice: the per-datum color, or the theme's.
216    fn slice_color(&self, datum: &T, cx: &App) -> Hsla {
217        match self.color.as_ref() {
218            Some(color_fn) => color_fn(datum),
219            None => cx.theme().chart_2,
220        }
221    }
222
223    /// How far the slice of datum `index` has lifted and how much it has faded
224    /// behind the hovered one this frame, as `(lift, opacity)`.
225    fn slice_emphasis(&self, index: usize) -> (f32, f32) {
226        let Some(hover) = self.hover.as_ref() else {
227            return (0., 1.);
228        };
229        let lift = hover.lift.get(index).copied().unwrap_or(0.) * hover.focus;
230        (lift, 1. - HOVER_DIM * hover.focus * (1. - lift))
231    }
232}
233
234impl<T> Plot for PieChart<T> {
235    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
236        if self.value.is_none() {
237            return;
238        }
239
240        let outer_radius = self.resolve_outer_radius(&bounds);
241
242        let arc = Arc::new()
243            .inner_radius(self.inner_radius)
244            .outer_radius(outer_radius);
245        let arcs = self.arcs();
246
247        // An identified chart keeps its slices tessellated across frames; without
248        // an id, sibling charts would share one cache and thrash it.
249        let caches = self
250            .id
251            .is_some()
252            .then(|| PathCaches::for_paint("slices", window, cx));
253        for (ix, a) in arcs.iter().enumerate() {
254            let inner_radius = self.get_inner_radius(a);
255            // The hovered slice lifts out of the ring while the others fade behind it.
256            let (lift, opacity) = self.slice_emphasis(a.index);
257            let outer_radius = self.get_outer_radius(a) + HOVER_LIFT * lift;
258            let color = self.slice_color(a.data, cx).opacity(opacity);
259            match caches.as_ref() {
260                Some(caches) => caches.update(cx, |caches, _| {
261                    arc.paint_cached(
262                        a,
263                        color,
264                        Some(inner_radius),
265                        Some(outer_radius),
266                        &bounds,
267                        caches.slot(ix),
268                        window,
269                    );
270                }),
271                None => arc.paint(
272                    a,
273                    color,
274                    Some(inner_radius),
275                    Some(outer_radius),
276                    &bounds,
277                    window,
278                ),
279            }
280        }
281
282        // Draw leader-line labels outside the ring (only when `label` is set).
283        let Some(label_fn) = self.label.as_ref() else {
284            return;
285        };
286
287        let label_radius = outer_radius + self.label_gap;
288        let center_x = bounds.size.width.as_f32() / 2.;
289        let center_y = bounds.size.height.as_f32() / 2.;
290        let label_arc = Arc::new()
291            .inner_radius(label_radius)
292            .outer_radius(label_radius);
293        let edge_arc = Arc::new()
294            .inner_radius(outer_radius)
295            .outer_radius(outer_radius);
296
297        let label_color = self.label_color.unwrap_or(cx.theme().foreground);
298        let default_line_color = cx.theme().border;
299
300        // First pass: collect a layout candidate per visible slice, split by
301        // side. `y` is the target vertical position relative to the center and
302        // gets adjusted later to remove overlaps.
303        let mut right: Vec<LabelLayout> = vec![];
304        let mut left: Vec<LabelLayout> = vec![];
305        for a in &arcs {
306            // Skip tiny slices (< 0.5°) that are too thin to label.
307            if a.end_angle - a.start_angle < std::f32::consts::PI / 360. {
308                continue;
309            }
310
311            let centroid = label_arc.centroid(a);
312            let edge = edge_arc.centroid(a);
313            let is_right = centroid.x > 0.;
314            let line_color = self
315                .label_line_color
316                .as_ref()
317                .map(|f| f(a.data))
318                .unwrap_or(default_line_color);
319
320            let layout = LabelLayout {
321                arc_x: edge.x,
322                arc_y: edge.y,
323                label_x: centroid.x,
324                y: centroid.y,
325                text: label_fn(a.data),
326                line_color,
327            };
328            if is_right { &mut right } else { &mut left }.push(layout);
329        }
330
331        // Second pass: spread labels on each side so neighbors keep at least one
332        // text height apart, clamped within the vertical bounds.
333        let top = -center_y + TEXT_HEIGHT / 2.;
334        let bottom = center_y - TEXT_HEIGHT / 2.;
335        spread_labels(&mut right, top, bottom);
336        spread_labels(&mut left, top, bottom);
337
338        // Third pass: paint leader lines first, then the text on top.
339        let mut labels = vec![];
340        for (side, items) in [(1., &right), (-1., &left)] {
341            for item in items {
342                // Leader line: ring edge -> label anchor -> horizontal pull to
343                // ±label_radius.
344                let pts = [
345                    point(item.arc_x + center_x, item.arc_y + center_y),
346                    point(item.label_x + center_x, item.y + center_y),
347                    point(side * label_radius + center_x, item.y + center_y),
348                ];
349                if let Some(p) = polygon(&pts, &bounds) {
350                    window.paint_path(p, item.line_color);
351                }
352
353                // Text sits 4px further out, aligned by side.
354                let origin = point(
355                    side * (label_radius + 4.) + center_x,
356                    item.y - TEXT_SIZE / 2. + center_y,
357                );
358                let align = if side > 0. {
359                    TextAlign::Left
360                } else {
361                    TextAlign::Right
362                };
363                labels.push(Text::new(item.text.clone(), origin, label_color).align(align));
364            }
365        }
366
367        PlotLabel::new(labels).paint(&bounds, window, cx);
368    }
369
370    fn id(&self) -> Option<ElementId> {
371        self.id.clone()
372    }
373
374    fn tooltip_state(
375        &self,
376        position: Point<Pixels>,
377        bounds: Bounds<Pixels>,
378        _cx: &App,
379    ) -> Option<TooltipState> {
380        let outer_radius = self.resolve_outer_radius(&bounds);
381        let arc = Arc::new()
382            .inner_radius(self.inner_radius)
383            .outer_radius(outer_radius);
384        let position = point(position.x.as_f32(), position.y.as_f32());
385
386        let index = self.arcs().into_iter().find_map(|a| {
387            arc.contains(
388                &a,
389                position,
390                Some(self.get_inner_radius(&a)),
391                Some(self.get_outer_radius(&a)),
392                &bounds,
393            )
394            .then_some(a.index)
395        })?;
396
397        Some(TooltipState::new(
398            index,
399            point(px(position.x), px(position.y)),
400            vec![],
401        ))
402    }
403
404    fn hover(&mut self, hover: Option<&PlotHover>, window: &mut Window, cx: &mut App) {
405        self.hover = hover.map(|hover| {
406            // Every slice springs toward lifted or resting, so the one the cursor
407            // left settles back while the new one rises. On the first hovered
408            // frame the target is rest, so the slice rises from the ring rather
409            // than adopting the lifted position outright.
410            let policy = cx.theme().motion_tokens().spring_control;
411            let lift = (0..self.data.len())
412                .map(|ix| {
413                    let lifted =
414                        hover.is_hovered() && !hover.is_entering() && ix == hover.state().index;
415                    spring(
416                        ElementId::named_usize("pie-slice", ix),
417                        if lifted { 1. } else { 0. },
418                        policy,
419                        window,
420                        cx,
421                    )
422                })
423                .collect();
424            PieHover {
425                lift,
426                focus: hover.focus(),
427            }
428        });
429    }
430
431    fn tooltip(
432        &self,
433        state: &TooltipState,
434        cursor: Point<Pixels>,
435        bounds: Bounds<Pixels>,
436        _window: &mut Window,
437        cx: &mut App,
438    ) -> Option<AnyElement> {
439        let value_fn = self.value.as_ref()?;
440        let d = self.data.get(state.index)?;
441        let value = value_fn(d);
442        let total: f32 = self.data.iter().map(|d| value_fn(d).max(0.)).sum();
443        let share = if total > 0. { value / total * 100. } else { 0. };
444        let name = self.name.clone().unwrap_or_default();
445
446        Some(
447            // Follow the cursor; the lifted slice marks the datum.
448            Tooltip::new(cursor, bounds.size)
449                .gap(px(8.))
450                .when_some(self.label.as_ref(), |this, label| this.title(label(d)))
451                .row(
452                    self.slice_color(d, cx),
453                    name,
454                    format!("{} ({:.1}%)", value, share),
455                )
456                .into_any_element(),
457        )
458    }
459}
460
461/// A resolved label position before overlap adjustment.
462struct LabelLayout {
463    /// Anchor on the ring edge (relative to center).
464    arc_x: f32,
465    arc_y: f32,
466    /// Centroid x at the label radius (relative to center).
467    label_x: f32,
468    /// Target/adjusted vertical position (relative to center).
469    y: f32,
470    text: SharedString,
471    line_color: Hsla,
472}
473
474/// Spread `items` vertically so that adjacent labels keep at least
475/// [`TEXT_HEIGHT`] apart, clamped within `[top, bottom]`.
476///
477/// Uses a two-direction relaxation: a top-down pass pushes crowded labels down,
478/// then a bottom-up pass (anchored at `bottom`) pushes them back up. This
479/// resolves cascading overlaps that a single-neighbor nudge cannot.
480fn spread_labels(items: &mut [LabelLayout], top: f32, bottom: f32) {
481    let n = items.len();
482    if n == 0 {
483        return;
484    }
485
486    // Sort by target position so neighbors in the slice are neighbors in y.
487    items.sort_by(|a, b| a.y.total_cmp(&b.y));
488
489    // Top-down: enforce the minimum gap by pushing labels down.
490    for i in 1..n {
491        let min_y = items[i - 1].y + TEXT_HEIGHT;
492        if items[i].y < min_y {
493            items[i].y = min_y;
494        }
495    }
496
497    // Bottom-up: clamp the bottom-most label, then pull overflowing labels up.
498    if items[n - 1].y > bottom {
499        items[n - 1].y = bottom;
500    }
501    for i in (0..n - 1).rev() {
502        let max_y = items[i + 1].y - TEXT_HEIGHT;
503        if items[i].y > max_y {
504            items[i].y = max_y;
505        }
506    }
507
508    // Keep the top-most label within bounds.
509    if items[0].y < top {
510        items[0].y = top;
511    }
512}