Skip to main content

gpui_component/chart/
radar_chart.rs

1use std::{
2    f32::consts::{PI, TAU},
3    rc::Rc,
4};
5
6use gpui::{
7    AnyElement, App, AvailableSpace, Background, Bounds, ElementId, Hsla, IntoElement, Pixels,
8    Point, SharedString, TextAlign, Window, point, px,
9};
10use gpui_component_macros::IntoPlot;
11use num_traits::{Num, ToPrimitive, Zero};
12
13use crate::{
14    ActiveTheme,
15    plot::{
16        Plot,
17        label::{PlotLabel, TEXT_SIZE, Text},
18        polygon,
19        scale::{Scale, ScaleLinear, Sealed},
20        shape::RadialLine,
21        tooltip::{Dot, Tooltip, TooltipState},
22    },
23};
24
25const HALF_PI: f32 = PI / 2.;
26
27/// The default extra gap (in pixels) between the outer grid ring and the labels.
28const DEFAULT_LABEL_GAP: f32 = 10.;
29
30/// The default number of concentric grid rings.
31const DEFAULT_GRID_LEVELS: usize = 4;
32
33/// The label of one radar dimension, returned by [`RadarChart::label`].
34pub enum RadarLabel {
35    /// Plain text, drawn by the plot's own text layer: honors
36    /// [`RadarChart::label_color`] and supplies the tooltip title.
37    Text(SharedString),
38    /// A custom element, measured at its natural size and anchored like a text
39    /// label. [`RadarChart::label_color`] does not apply, and it supplies no
40    /// tooltip title.
41    Element(AnyElement),
42}
43
44impl From<&'static str> for RadarLabel {
45    fn from(text: &'static str) -> Self {
46        Self::Text(text.into())
47    }
48}
49
50impl From<String> for RadarLabel {
51    fn from(text: String) -> Self {
52        Self::Text(text.into())
53    }
54}
55
56impl From<SharedString> for RadarLabel {
57    fn from(text: SharedString) -> Self {
58        Self::Text(text)
59    }
60}
61
62impl From<AnyElement> for RadarLabel {
63    fn from(element: AnyElement) -> Self {
64        Self::Element(element)
65    }
66}
67
68/// A radar (spider) chart.
69///
70/// Each datum is one dimension (a spoke), placed clockwise around the center
71/// starting at 12 o'clock. Add one series per [`RadarChart::value`] call; each
72/// series is drawn as a closed polygon connecting its values on every spoke.
73#[derive(IntoPlot)]
74pub struct RadarChart<T, Y>
75where
76    T: 'static,
77    Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
78{
79    data: Vec<T>,
80    values: Vec<Rc<dyn Fn(&T) -> Y>>,
81    strokes: Vec<Hsla>,
82    fills: Vec<Background>,
83    names: Vec<SharedString>,
84    label: Option<Rc<dyn Fn(&T) -> RadarLabel + 'static>>,
85    /// The text of each dimension's label, resolved once per frame in `prepaint`;
86    /// element labels leave `None`. Read by `paint` (to draw them) and `tooltip`
87    /// (as the title), so the label closure runs once per dimension per frame.
88    label_texts: Vec<Option<SharedString>>,
89    label_color: Option<Hsla>,
90    label_gap: f32,
91    max_value: Option<Y>,
92    outer_radius: f32,
93    grid: bool,
94    grid_levels: usize,
95    dot: bool,
96    id: Option<ElementId>,
97}
98
99impl<T, Y> RadarChart<T, Y>
100where
101    Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
102{
103    pub fn new<I>(data: I) -> Self
104    where
105        I: IntoIterator<Item = T>,
106    {
107        Self {
108            data: data.into_iter().collect(),
109            values: vec![],
110            strokes: vec![],
111            fills: vec![],
112            names: vec![],
113            label: None,
114            label_texts: vec![],
115            label_color: None,
116            label_gap: DEFAULT_LABEL_GAP,
117            max_value: None,
118            outer_radius: 0.,
119            grid: true,
120            grid_levels: DEFAULT_GRID_LEVELS,
121            dot: false,
122            id: None,
123        }
124    }
125
126    /// Enable an interactive hover tooltip (a dot and row per series at the
127    /// hovered dimension).
128    ///
129    /// The `id` must be unique among sibling elements. Without it, the chart
130    /// stays a non-interactive plot.
131    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
132        self.id = Some(id.into());
133        self
134    }
135
136    /// Set the name of the most recently added series, shown in its tooltip row.
137    ///
138    /// Call after the matching [`RadarChart::value`]
139    /// (e.g. `.value(..).stroke(..).name("Desktop")`).
140    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
141        self.names.push(name.into());
142        self
143    }
144
145    /// Add a series to the radar chart.
146    ///
147    /// Call multiple times to overlay multiple series, each paired with the
148    /// matching [`RadarChart::stroke`] and [`RadarChart::fill`] calls.
149    pub fn value(mut self, value: impl Fn(&T) -> Y + 'static) -> Self {
150        self.values.push(Rc::new(value));
151        self
152    }
153
154    /// Set the stroke color of the most recently added series.
155    ///
156    /// Defaults to the theme chart colors, cycled per series.
157    pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
158        self.strokes.push(stroke.into());
159        self
160    }
161
162    /// Set the fill color of the most recently added series.
163    ///
164    /// Defaults to the series stroke color with 0.3 opacity.
165    pub fn fill(mut self, fill: impl Into<Background>) -> Self {
166        self.fills.push(fill.into());
167        self
168    }
169
170    /// Set the label for each dimension, shown outside the outer ring.
171    ///
172    /// Return a string for a plain text label, or `element.into_any_element()`
173    /// for a custom one; see [`RadarLabel`] for how the two differ.
174    ///
175    /// ```ignore
176    /// RadarChart::new(data).label(|d| d.month.clone())
177    ///
178    /// RadarChart::new(data).label(|d| {
179    ///     v_flex()
180    ///         .items_center()
181    ///         .child(Icon::new(IconName::Star).xsmall())
182    ///         .child(d.month.clone())
183    ///         .into_any_element()
184    /// })
185    /// ```
186    pub fn label<L>(mut self, label: impl Fn(&T) -> L + 'static) -> Self
187    where
188        L: Into<RadarLabel> + 'static,
189    {
190        self.label = Some(Rc::new(move |d| label(d).into()));
191        self
192    }
193
194    /// Set the text label color (defaults to `cx.theme().muted_foreground`).
195    ///
196    /// Element labels style themselves; this does not apply to them.
197    pub fn label_color(mut self, color: impl Into<Hsla>) -> Self {
198        self.label_color = Some(color.into());
199        self
200    }
201
202    /// Set the extra gap between the outer ring and the labels
203    /// (defaults to 10px).
204    pub fn label_gap(mut self, gap: f32) -> Self {
205        self.label_gap = gap;
206        self
207    }
208
209    /// Set the value at the outer ring.
210    ///
211    /// Defaults to the maximum value across all series.
212    pub fn max_value(mut self, max_value: Y) -> Self {
213        self.max_value = Some(max_value);
214        self
215    }
216
217    /// Set the outer radius of the radar chart.
218    ///
219    /// Defaults to 40% of the bounds height.
220    pub fn outer_radius(mut self, outer_radius: f32) -> Self {
221        self.outer_radius = outer_radius;
222        self
223    }
224
225    /// Show or hide the grid rings and spokes.
226    ///
227    /// Default is true.
228    pub fn grid(mut self, grid: bool) -> Self {
229        self.grid = grid;
230        self
231    }
232
233    /// Set the number of concentric grid rings (defaults to 4).
234    pub fn grid_levels(mut self, grid_levels: usize) -> Self {
235        self.grid_levels = grid_levels.max(1);
236        self
237    }
238
239    /// Show dots on the vertices of each series.
240    pub fn dot(mut self) -> Self {
241        self.dot = true;
242        self
243    }
244
245    /// The stroke color of the series at the given index, set or default.
246    ///
247    /// Defaults to the theme chart colors, cycled per series.
248    fn series_stroke(&self, ix: usize, cx: &App) -> Hsla {
249        let colors = [
250            cx.theme().chart_1,
251            cx.theme().chart_2,
252            cx.theme().chart_3,
253            cx.theme().chart_4,
254            cx.theme().chart_5,
255        ];
256
257        self.strokes
258            .get(ix)
259            .copied()
260            .unwrap_or(colors[ix % colors.len()])
261    }
262
263    /// The resolved outer radius for the given bounds.
264    fn resolve_outer_radius(&self, bounds: &Bounds<Pixels>) -> f32 {
265        if self.outer_radius.is_zero() {
266            bounds.size.height.as_f32() * 0.4
267        } else {
268            self.outer_radius
269        }
270    }
271
272    /// Where the label of dimension `ix` attaches, in bounds-relative coordinates,
273    /// plus the outward radial direction at that dimension (a unit vector).
274    ///
275    /// The anchor sits on the label ring at the dimension's angle; callers offset
276    /// their own box from it along `direction`. Shared by `prepaint` and `paint`
277    /// so element and text labels land in the same place.
278    fn label_anchor(
279        &self,
280        ix: usize,
281        outer_radius: f32,
282        bounds: &Bounds<Pixels>,
283    ) -> (Point<f32>, Point<f32>) {
284        let label_radius = outer_radius + self.label_gap;
285        let angle = ix as f32 * TAU / self.data.len() as f32 - HALF_PI;
286        let direction = point(angle.cos(), angle.sin());
287
288        let anchor = point(
289            bounds.size.width.as_f32() / 2. + label_radius * direction.x,
290            bounds.size.height.as_f32() / 2. + label_radius * direction.y,
291        );
292
293        (anchor, direction)
294    }
295
296    /// Build the radius scale from the center to the outer ring.
297    ///
298    /// The domain includes zero so non-negative data starts at the center.
299    /// Shared by `paint` and `tooltip_state` so the two stay in sync.
300    fn scale(&self, outer_radius: f32) -> ScaleLinear<Y> {
301        let domain = if let Some(max_value) = self.max_value {
302            vec![Y::zero(), max_value]
303        } else {
304            self.data
305                .iter()
306                .flat_map(|d| self.values.iter().map(|value_fn| value_fn(d)))
307                .chain(Some(Y::zero()))
308                .collect()
309        };
310
311        ScaleLinear::new(domain, vec![0., outer_radius])
312    }
313
314    /// Map a cursor position to the nearest spoke index, or `None` when the
315    /// cursor is outside the radar.
316    fn hovered_index(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> Option<usize> {
317        let n = self.data.len();
318        if n == 0 {
319            return None;
320        }
321
322        let outer_radius = self.resolve_outer_radius(&bounds);
323        let dx = position.x.as_f32() - bounds.size.width.as_f32() / 2.;
324        let dy = position.y.as_f32() - bounds.size.height.as_f32() / 2.;
325        if dx.hypot(dy) > outer_radius + self.label_gap {
326            return None;
327        }
328
329        // Screen angle -> chart angle (0 at 12 o'clock, clockwise).
330        let angle = (dy.atan2(dx) + HALF_PI).rem_euclid(TAU);
331        Some((angle * n as f32 / TAU).round() as usize % n)
332    }
333}
334
335impl<T, Y> Plot for RadarChart<T, Y>
336where
337    Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
338{
339    /// Resolve every dimension's label, keeping the text ones for `paint` and
340    /// laying out the element ones here (measuring is illegal in `paint`).
341    fn prepaint(
342        &mut self,
343        bounds: Bounds<Pixels>,
344        window: &mut Window,
345        cx: &mut App,
346    ) -> Vec<AnyElement> {
347        self.label_texts.clear();
348
349        // Same guard as `paint`: without a series nothing is drawn at all.
350        let n = self.data.len();
351        if n == 0 || self.values.is_empty() {
352            return vec![];
353        }
354        let Some(label_fn) = self.label.clone() else {
355            return vec![];
356        };
357
358        let outer_radius = self.resolve_outer_radius(&bounds);
359        let mut texts = Vec::with_capacity(n);
360        let mut elements = vec![];
361
362        for (ix, d) in self.data.iter().enumerate() {
363            match label_fn(d) {
364                RadarLabel::Text(text) => texts.push(Some(text)),
365                RadarLabel::Element(mut element) => {
366                    texts.push(None);
367
368                    // Only `AvailableSpace::Definite` makes text wrap, so this
369                    // measures the label at its natural, unwrapped size.
370                    let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
371                    let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds);
372
373                    // Push the box radially outward until its inner edge meets the
374                    // anchor, so a tall label clears the ring instead of straddling
375                    // it. Reduces to "centered on the anchor" across that axis when
376                    // the dimension is square-on to it.
377                    let origin = bounds.origin
378                        + point(
379                            px(anchor.x + (direction.x - 1.) * size.width.as_f32() / 2.),
380                            px(anchor.y + (direction.y - 1.) * size.height.as_f32() / 2.),
381                        );
382
383                    element.prepaint_at(origin, window, cx);
384                    elements.push(element);
385                }
386            }
387        }
388
389        self.label_texts = texts;
390
391        elements
392    }
393
394    fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
395        let n = self.data.len();
396        if n == 0 || self.values.is_empty() {
397            return;
398        }
399
400        let outer_radius = self.resolve_outer_radius(&bounds);
401        let angle_step = TAU / n as f32;
402        let center_x = bounds.size.width.as_f32() / 2.;
403        let center_y = bounds.size.height.as_f32() / 2.;
404        let scale = self.scale(outer_radius);
405
406        // Draw grid rings and spokes
407        if self.grid {
408            let stroke = cx.theme().border;
409
410            for level in 1..=self.grid_levels {
411                let radius = outer_radius * level as f32 / self.grid_levels as f32;
412                RadialLine::new()
413                    .data(0..n)
414                    .angle(move |_, i| Some(i as f32 * angle_step))
415                    .radius(move |_, _| Some(radius))
416                    .closed()
417                    .stroke(stroke)
418                    .paint(&bounds, window);
419            }
420
421            for i in 0..n {
422                let angle = i as f32 * angle_step - HALF_PI;
423                let points = [
424                    point(center_x, center_y),
425                    point(
426                        center_x + outer_radius * angle.cos(),
427                        center_y + outer_radius * angle.sin(),
428                    ),
429                ];
430                if let Some(path) = polygon(&points, &bounds) {
431                    window.paint_path(path, stroke);
432                }
433            }
434        }
435
436        // Draw series
437        for (i, value_fn) in self.values.iter().enumerate() {
438            let stroke = self.series_stroke(i, cx);
439            let fill = self
440                .fills
441                .get(i)
442                .copied()
443                .unwrap_or_else(|| stroke.opacity(0.3).into());
444
445            let scale = scale.clone();
446            let value_fn = value_fn.clone();
447            let mut line = RadialLine::new()
448                .data(&self.data)
449                .angle(move |_, i| Some(i as f32 * angle_step))
450                .radius(move |d, _| scale.tick(&value_fn(d)))
451                .closed()
452                .fill(fill)
453                .stroke(stroke)
454                .stroke_width(2.);
455            if self.dot {
456                line = line.dot().dot_size(8.).dot_fill_color(stroke);
457            }
458            line.paint(&bounds, window);
459        }
460
461        // Draw the text labels outside the outer ring; `prepaint` resolved them and
462        // already placed the element ones.
463        let label_color = self.label_color.unwrap_or(cx.theme().muted_foreground);
464        let labels = self
465            .label_texts
466            .iter()
467            .enumerate()
468            .filter_map(|(ix, text)| {
469                let text = text.clone()?;
470                let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds);
471
472                // Labels on the right are left-aligned, on the left right-aligned,
473                // and near the vertical axis centered. `direction` is a unit vector,
474                // so the epsilon only absorbs float noise at 12 and 6 o'clock.
475                let align = if direction.x > 1e-3 {
476                    TextAlign::Left
477                } else if direction.x < -1e-3 {
478                    TextAlign::Right
479                } else {
480                    TextAlign::Center
481                };
482
483                Some(
484                    Text::new(
485                        text,
486                        point(px(anchor.x), px(anchor.y - TEXT_SIZE / 2.)),
487                        label_color,
488                    )
489                    .align(align),
490                )
491            });
492
493        PlotLabel::new(labels.collect()).paint(&bounds, window, cx);
494    }
495
496    fn id(&self) -> Option<ElementId> {
497        self.id.clone()
498    }
499
500    fn tooltip_state(
501        &self,
502        position: Point<Pixels>,
503        bounds: Bounds<Pixels>,
504        _cx: &App,
505    ) -> Option<TooltipState> {
506        if self.values.is_empty() {
507            return None;
508        }
509        let index = self.hovered_index(position, bounds)?;
510        let d = self.data.get(index)?;
511
512        let outer_radius = self.resolve_outer_radius(&bounds);
513        let scale = self.scale(outer_radius);
514        let center_x = bounds.size.width.as_f32() / 2.;
515        let center_y = bounds.size.height.as_f32() / 2.;
516        let angle = index as f32 * TAU / self.data.len() as f32 - HALF_PI;
517
518        // One dot per series at the hovered dimension's vertex.
519        let dots = self
520            .values
521            .iter()
522            .filter_map(|value_fn| {
523                let radius = scale.tick(&value_fn(d))?;
524                Some(point(
525                    px(center_x + radius * angle.cos()),
526                    px(center_y + radius * angle.sin()),
527                ))
528            })
529            .collect();
530
531        Some(TooltipState::new(index, position, dots))
532    }
533
534    fn tooltip(
535        &self,
536        state: &TooltipState,
537        cursor: Point<Pixels>,
538        bounds: Bounds<Pixels>,
539        _window: &mut Window,
540        cx: &mut App,
541    ) -> Option<AnyElement> {
542        let d = self.data.get(state.index)?;
543
544        let dot_stroke = cx.theme().background;
545
546        // No crosshair: a radar has no cartesian axis to snap to; the dots mark
547        // the hovered dimension's vertices instead.
548        let mut tooltip =
549            Tooltip::new(cursor, bounds.size)
550                .gap(px(8.))
551                .dots(state.dots.iter().enumerate().map(|(i, p)| {
552                    Dot::new(*p)
553                        .stroke(dot_stroke)
554                        .fill(self.series_stroke(i, cx))
555                }));
556
557        // Filled by `prepaint`, which runs first; element labels leave no title.
558        if let Some(title) = self.label_texts.get(state.index).cloned().flatten() {
559            tooltip = tooltip.title(title);
560        }
561
562        // One row per series: swatch + label + value.
563        for (i, value_fn) in self.values.iter().enumerate() {
564            let name = self.names.get(i).cloned().unwrap_or_default();
565            let value = value_fn(d).to_f64()?;
566            tooltip = tooltip.row(self.series_stroke(i, cx), name, format!("{}", value));
567        }
568
569        Some(tooltip.into_any_element())
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[derive(Clone)]
578    struct Item {
579        subject: SharedString,
580        a: f64,
581        b: f64,
582    }
583
584    #[test]
585    fn test_radar_chart_builder() {
586        let data = vec![
587            Item {
588                subject: "Sales".into(),
589                a: 80.,
590                b: 60.,
591            },
592            Item {
593                subject: "Marketing".into(),
594                a: 50.,
595                b: 90.,
596            },
597        ];
598
599        let chart = RadarChart::new(data.clone())
600            .label(|d| d.subject.clone())
601            .value(|d| d.a)
602            .stroke(gpui::red())
603            .fill(gpui::red())
604            .name("A")
605            .value(|d| d.b)
606            .max_value(100.)
607            .outer_radius(120.)
608            .label_gap(8.)
609            .grid(false)
610            .grid_levels(5)
611            .dot()
612            .id("radar");
613
614        assert_eq!(chart.data.len(), 2);
615        assert_eq!(chart.values.len(), 2);
616        assert_eq!(chart.strokes.len(), 1);
617        assert_eq!(chart.fills.len(), 1);
618        assert_eq!(chart.names.len(), 1);
619        assert!(chart.label.is_some());
620        assert_eq!(chart.max_value, Some(100.));
621        assert_eq!(chart.outer_radius, 120.);
622        assert_eq!(chart.label_gap, 8.);
623        assert!(!chart.grid);
624        assert_eq!(chart.grid_levels, 5);
625        assert!(chart.dot);
626        assert!(chart.id.is_some());
627
628        let values = (chart.values[0](&data[0]), chart.values[1](&data[0]));
629        assert_eq!(values, (80., 60.));
630    }
631
632    /// Every string form the `label` closure may return lands on the text path,
633    /// which is what keeps `label_color` and the tooltip title working.
634    #[test]
635    fn test_radar_label_from_text() {
636        let labels = [
637            RadarLabel::from("Sales"),
638            RadarLabel::from("Sales".to_string()),
639            RadarLabel::from(SharedString::from("Sales")),
640        ];
641
642        for label in labels {
643            assert!(matches!(label, RadarLabel::Text(text) if text == "Sales"));
644        }
645    }
646
647    #[test]
648    fn test_radar_chart_grid_levels_min() {
649        let chart: RadarChart<Item, f64> = RadarChart::new(vec![]).grid_levels(0);
650        assert_eq!(chart.grid_levels, 1);
651    }
652
653    #[test]
654    fn test_radar_chart_hovered_index() {
655        let data = (0..4)
656            .map(|i| Item {
657                subject: format!("S{}", i).into(),
658                a: 50.,
659                b: 50.,
660            })
661            .collect::<Vec<_>>();
662
663        // Bounds 200x200 => center (100, 100), default outer radius 80,
664        // hover region 80 + 10 (label gap) = 90.
665        let chart: RadarChart<Item, f64> = RadarChart::new(data).value(|d| d.a);
666        let bounds = gpui::Bounds::new(point(px(0.), px(0.)), gpui::size(px(200.), px(200.)));
667
668        // The four spokes point at 12, 3, 6 and 9 o'clock.
669        assert_eq!(
670            chart.hovered_index(point(px(100.), px(30.)), bounds),
671            Some(0)
672        );
673        assert_eq!(
674            chart.hovered_index(point(px(170.), px(100.)), bounds),
675            Some(1)
676        );
677        assert_eq!(
678            chart.hovered_index(point(px(100.), px(170.)), bounds),
679            Some(2)
680        );
681        assert_eq!(
682            chart.hovered_index(point(px(30.), px(100.)), bounds),
683            Some(3)
684        );
685
686        // Nearest spoke wins between two spokes.
687        assert_eq!(
688            chart.hovered_index(point(px(110.), px(40.)), bounds),
689            Some(0)
690        );
691        assert_eq!(
692            chart.hovered_index(point(px(160.), px(90.)), bounds),
693            Some(1)
694        );
695
696        // Outside the radar.
697        assert_eq!(chart.hovered_index(point(px(100.), px(5.)), bounds), None);
698        assert_eq!(chart.hovered_index(point(px(5.), px(5.)), bounds), None);
699    }
700}