Skip to main content

gpui_component/chart/
radar_chart.rs

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