Skip to main content

gpui_macos/chart/
radar_chart.rs

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