Skip to main content

gpui_base/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: Background,
38    dot_stroke: 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: gpui::transparent_black().into(),
54            dot_stroke: 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    /// Draw a dot on every point.
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 of the dots on the RadialLine.
137    pub fn dot_fill(mut self, fill: impl Into<Background>) -> Self {
138        self.dot_fill = fill.into();
139        self
140    }
141
142    /// Set the 1px border color of the dots on the RadialLine. Defaults to the
143    /// dot fill when it is a solid color.
144    pub fn dot_stroke(mut self, stroke: impl Into<Hsla>) -> Self {
145        self.dot_stroke = Some(stroke.into());
146        self
147    }
148
149    #[deprecated(since = "0.7.0", note = "use `dot_fill`")]
150    pub fn dot_fill_color(self, color: impl Into<Hsla>) -> Self {
151        self.dot_fill(color.into())
152    }
153
154    #[deprecated(since = "0.7.0", note = "use `dot_stroke`")]
155    pub fn dot_stroke_color(self, color: impl Into<Hsla>) -> Self {
156        self.dot_stroke(color)
157    }
158
159    /// Paint a dot on the RadialLine.
160    fn paint_dot(&self, dot: Point<Pixels>) -> PaintQuad {
161        quad(
162            gpui::bounds(dot, size(self.dot_size, self.dot_size)),
163            self.dot_size / 2.,
164            self.dot_fill,
165            px(1.),
166            self.dot_stroke
167                .or_else(|| self.dot_fill.as_solid())
168                .unwrap_or_default(),
169            BorderStyle::default(),
170        )
171    }
172
173    /// Resolve the data to points around the center of the bounds.
174    fn points(&self, bounds: &Bounds<Pixels>) -> Vec<Point<Pixels>> {
175        let center_x = bounds.origin.x.as_f32() + bounds.size.width.as_f32() / 2.;
176        let center_y = bounds.origin.y.as_f32() + bounds.size.height.as_f32() / 2.;
177
178        self.data
179            .iter()
180            .enumerate()
181            .filter_map(|(i, v)| {
182                let angle = (self.angle)(v, i)? - HALF_PI;
183                let radius = (self.radius)(v, i)?;
184
185                Some(point(
186                    px(center_x + radius * angle.cos()),
187                    px(center_y + radius * angle.sin()),
188                ))
189            })
190            .collect()
191    }
192
193    fn path(
194        &self,
195        bounds: &Bounds<Pixels>,
196    ) -> (Option<Path<Pixels>>, Option<Path<Pixels>>, Vec<PaintQuad>) {
197        let points = self.points(bounds);
198        let mut paint_dots = vec![];
199
200        if self.dot {
201            let dot_radius = self.dot_size / 2.;
202            for p in &points {
203                paint_dots.push(self.paint_dot(point(p.x - dot_radius, p.y - dot_radius)));
204            }
205        }
206
207        if points.is_empty() {
208            return (None, None, paint_dots);
209        }
210
211        let fill_path = self.fill.and_then(|_| {
212            if points.len() < 3 {
213                return None;
214            }
215
216            let mut builder = PathBuilder::fill();
217            builder.add_polygon(&points, true);
218            builder.build().ok()
219        });
220
221        let mut builder = PathBuilder::stroke(self.stroke_width);
222        builder.move_to(points[0]);
223        for p in &points[1..] {
224            builder.line_to(*p);
225        }
226        if self.closed && points.len() > 2 {
227            builder.close();
228        }
229
230        (fill_path, builder.build().ok(), paint_dots)
231    }
232
233    /// Paint the RadialLine.
234    pub fn paint(&self, bounds: &Bounds<Pixels>, window: &mut Window) {
235        let (fill_path, stroke_path, dots) = self.path(bounds);
236
237        if let (Some(path), Some(fill)) = (fill_path, self.fill) {
238            window.paint_path(path, fill);
239        }
240        if let Some(path) = stroke_path {
241            window.paint_path(path, self.stroke);
242        }
243        for dot in dots {
244            window.paint_quad(dot);
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use std::f32::consts::TAU;
252
253    use super::*;
254
255    use gpui::{Bounds, point, px};
256
257    #[test]
258    fn test_radial_line_points() {
259        let data = vec![1., 1., 1., 1.];
260        let line = RadialLine::new()
261            .data(data)
262            .angle(|_, i| Some(i as f32 * TAU / 4.))
263            .radius(|v, _| Some(*v * 10.));
264
265        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(100.)));
266        let points = line.points(&bounds);
267
268        // 4 points around the center (50, 50), at 12, 3, 6 and 9 o'clock.
269        assert_eq!(points.len(), 4);
270        let expected = [(50., 40.), (60., 50.), (50., 60.), (40., 50.)];
271        for (p, (x, y)) in points.iter().zip(expected) {
272            assert!((p.x.as_f32() - x).abs() < 1e-4);
273            assert!((p.y.as_f32() - y).abs() < 1e-4);
274        }
275    }
276
277    #[test]
278    fn test_radial_line_path() {
279        let data = vec![1., 2., 3.];
280        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(100.), px(100.)));
281
282        let line = RadialLine::new()
283            .data(data.clone())
284            .angle(|_, i| Some(i as f32 * TAU / 3.))
285            .radius(|v, _| Some(*v * 10.));
286
287        let (fill_path, stroke_path, dots) = line.path(&bounds);
288        assert!(fill_path.is_none());
289        assert!(stroke_path.is_some());
290        assert!(dots.is_empty());
291
292        let line = RadialLine::new()
293            .data(data)
294            .angle(|_, i| Some(i as f32 * TAU / 3.))
295            .radius(|v, _| Some(*v * 10.))
296            .closed()
297            .fill(gpui::black())
298            .dot();
299
300        let (fill_path, stroke_path, dots) = line.path(&bounds);
301        assert!(fill_path.is_some());
302        assert!(stroke_path.is_some());
303        assert_eq!(dots.len(), 3);
304    }
305}