Skip to main content

gpui_component/plot/shape/
radial_line.rs

1// @reference: https://d3js.org/d3-shape/radial-line
2
3use std::f32::consts::PI;
4
5use gpui::{
6    Background, BorderStyle, Bounds, Hsla, PaintQuad, Path, PathBuilder, Pixels, Point, Window,
7    point, px, quad, size,
8};
9
10const HALF_PI: f32 = PI / 2.;
11
12/// A radial line generator, like `d3.lineRadial`.
13///
14/// Points are placed around the center of the plot bounds. The `angle`
15/// accessor returns the angle in radians, with 0 at 12 o'clock and positive
16/// angles proceeding clockwise. The `radius` accessor returns the distance
17/// (in pixels) from the center.
18///
19/// Call [`RadialLine::closed`] to connect the last point back to the first
20/// (like `d3.curveLinearClosed`), and [`RadialLine::fill`] to fill the
21/// enclosed polygon, e.g. for radar charts.
22///
23/// Unlike [`Line`](super::Line), the accessors also receive the datum index,
24/// matching d3's `(d, i)` accessor form, since radial charts typically derive
25/// the angle from the index (e.g. `i * TAU / n`).
26#[allow(clippy::type_complexity)]
27pub struct RadialLine<T> {
28    data: Vec<T>,
29    angle: Box<dyn Fn(&T, usize) -> Option<f32>>,
30    radius: Box<dyn Fn(&T, usize) -> Option<f32>>,
31    closed: bool,
32    fill: Option<Background>,
33    stroke: Background,
34    stroke_width: Pixels,
35    dot: bool,
36    dot_size: Pixels,
37    dot_fill_color: Hsla,
38    dot_stroke_color: Option<Hsla>,
39}
40
41impl<T> Default for RadialLine<T> {
42    fn default() -> Self {
43        Self {
44            data: Vec::new(),
45            angle: Box::new(|_, _| None),
46            radius: Box::new(|_, _| None),
47            closed: false,
48            fill: None,
49            stroke: Default::default(),
50            stroke_width: px(1.),
51            dot: false,
52            dot_size: px(4.),
53            dot_fill_color: gpui::transparent_black(),
54            dot_stroke_color: None,
55        }
56    }
57}
58
59impl<T> RadialLine<T> {
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Set the data of the RadialLine.
65    pub fn data<I>(mut self, data: I) -> Self
66    where
67        I: IntoIterator<Item = T>,
68    {
69        self.data = data.into_iter().collect();
70        self
71    }
72
73    /// Set the angle accessor of the RadialLine.
74    ///
75    /// The accessor is called with the datum and its index, and returns the
76    /// angle in radians, with 0 at 12 o'clock and positive angles proceeding
77    /// clockwise.
78    pub fn angle<F>(mut self, angle: F) -> Self
79    where
80        F: Fn(&T, usize) -> Option<f32> + 'static,
81    {
82        self.angle = Box::new(angle);
83        self
84    }
85
86    /// Set the radius accessor of the RadialLine.
87    ///
88    /// The accessor is called with the datum and its index, and returns the
89    /// distance (in pixels) from the center.
90    pub fn radius<F>(mut self, radius: F) -> Self
91    where
92        F: Fn(&T, usize) -> Option<f32> + 'static,
93    {
94        self.radius = Box::new(radius);
95        self
96    }
97
98    /// Connect the last point back to the first, like `d3.curveLinearClosed`.
99    pub fn closed(mut self) -> Self {
100        self.closed = true;
101        self
102    }
103
104    /// Set the fill color of the polygon enclosed by the RadialLine.
105    ///
106    /// The fill path is always closed, regardless of [`RadialLine::closed`].
107    pub fn fill(mut self, fill: impl Into<Background>) -> Self {
108        self.fill = Some(fill.into());
109        self
110    }
111
112    /// Set the stroke color of the RadialLine.
113    pub fn stroke(mut self, stroke: impl Into<Background>) -> Self {
114        self.stroke = stroke.into();
115        self
116    }
117
118    /// Set the stroke width of the RadialLine.
119    pub fn stroke_width(mut self, stroke_width: impl Into<Pixels>) -> Self {
120        self.stroke_width = stroke_width.into();
121        self
122    }
123
124    /// Show dots on the RadialLine.
125    pub fn dot(mut self) -> Self {
126        self.dot = true;
127        self
128    }
129
130    /// Set the size of the dots on the RadialLine.
131    pub fn dot_size(mut self, dot_size: impl Into<Pixels>) -> Self {
132        self.dot_size = dot_size.into();
133        self
134    }
135
136    /// Set the fill color of the dots on the RadialLine.
137    pub fn dot_fill_color(mut self, dot_fill_color: impl Into<Hsla>) -> Self {
138        self.dot_fill_color = dot_fill_color.into();
139        self
140    }
141
142    /// Set the stroke color of the dots on the RadialLine.
143    pub fn dot_stroke_color(mut self, dot_stroke_color: impl Into<Hsla>) -> Self {
144        self.dot_stroke_color = Some(dot_stroke_color.into());
145        self
146    }
147
148    /// Paint a dot on the RadialLine.
149    fn paint_dot(&self, dot: Point<Pixels>) -> PaintQuad {
150        quad(
151            gpui::bounds(dot, size(self.dot_size, self.dot_size)),
152            self.dot_size / 2.,
153            self.dot_fill_color,
154            px(1.),
155            self.dot_stroke_color.unwrap_or(self.dot_fill_color),
156            BorderStyle::default(),
157        )
158    }
159
160    /// Resolve the data to points around the center of the bounds.
161    fn points(&self, bounds: &Bounds<Pixels>) -> Vec<Point<Pixels>> {
162        let center_x = bounds.origin.x.as_f32() + bounds.size.width.as_f32() / 2.;
163        let center_y = bounds.origin.y.as_f32() + bounds.size.height.as_f32() / 2.;
164
165        self.data
166            .iter()
167            .enumerate()
168            .filter_map(|(i, v)| {
169                let angle = (self.angle)(v, i)? - HALF_PI;
170                let radius = (self.radius)(v, i)?;
171
172                Some(point(
173                    px(center_x + radius * angle.cos()),
174                    px(center_y + radius * angle.sin()),
175                ))
176            })
177            .collect()
178    }
179
180    fn path(
181        &self,
182        bounds: &Bounds<Pixels>,
183    ) -> (Option<Path<Pixels>>, Option<Path<Pixels>>, Vec<PaintQuad>) {
184        let points = self.points(bounds);
185        let mut paint_dots = vec![];
186
187        if self.dot {
188            let dot_radius = self.dot_size / 2.;
189            for p in &points {
190                paint_dots.push(self.paint_dot(point(p.x - dot_radius, p.y - dot_radius)));
191            }
192        }
193
194        if points.is_empty() {
195            return (None, None, paint_dots);
196        }
197
198        let fill_path = self.fill.and_then(|_| {
199            if points.len() < 3 {
200                return None;
201            }
202
203            let mut builder = PathBuilder::fill();
204            builder.add_polygon(&points, true);
205            builder.build().ok()
206        });
207
208        let mut builder = PathBuilder::stroke(self.stroke_width);
209        builder.move_to(points[0]);
210        for p in &points[1..] {
211            builder.line_to(*p);
212        }
213        if self.closed && points.len() > 2 {
214            builder.close();
215        }
216
217        (fill_path, builder.build().ok(), paint_dots)
218    }
219
220    /// Paint the RadialLine.
221    pub fn paint(&self, bounds: &Bounds<Pixels>, window: &mut Window) {
222        let (fill_path, stroke_path, dots) = self.path(bounds);
223
224        if let (Some(path), Some(fill)) = (fill_path, self.fill) {
225            window.paint_path(path, fill);
226        }
227        if let Some(path) = stroke_path {
228            window.paint_path(path, self.stroke);
229        }
230        for dot in dots {
231            window.paint_quad(dot);
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use std::f32::consts::TAU;
239
240    use super::*;
241
242    use gpui::{Bounds, point, px};
243
244    #[test]
245    fn test_radial_line_points() {
246        let data = vec![1., 1., 1., 1.];
247        let line = RadialLine::new()
248            .data(data)
249            .angle(|_, i| Some(i as f32 * TAU / 4.))
250            .radius(|v, _| Some(*v * 10.));
251
252        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(100.)));
253        let points = line.points(&bounds);
254
255        // 4 points around the center (50, 50), at 12, 3, 6 and 9 o'clock.
256        assert_eq!(points.len(), 4);
257        let expected = [(50., 40.), (60., 50.), (50., 60.), (40., 50.)];
258        for (p, (x, y)) in points.iter().zip(expected) {
259            assert!((p.x.as_f32() - x).abs() < 1e-4);
260            assert!((p.y.as_f32() - y).abs() < 1e-4);
261        }
262    }
263
264    #[test]
265    fn test_radial_line_path() {
266        let data = vec![1., 2., 3.];
267        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(100.)));
268
269        let line = RadialLine::new()
270            .data(data.clone())
271            .angle(|_, i| Some(i as f32 * TAU / 3.))
272            .radius(|v, _| Some(*v * 10.));
273
274        let (fill_path, stroke_path, dots) = line.path(&bounds);
275        assert!(fill_path.is_none());
276        assert!(stroke_path.is_some());
277        assert!(dots.is_empty());
278
279        let line = RadialLine::new()
280            .data(data)
281            .angle(|_, i| Some(i as f32 * TAU / 3.))
282            .radius(|v, _| Some(*v * 10.))
283            .closed()
284            .fill(gpui::black())
285            .dot();
286
287        let (fill_path, stroke_path, dots) = line.path(&bounds);
288        assert!(fill_path.is_some());
289        assert!(stroke_path.is_some());
290        assert_eq!(dots.len(), 3);
291    }
292}