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}