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