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
28const DEFAULT_LABEL_GAP: f32 = 10.;
30
31const DEFAULT_GRID_LEVELS: usize = 4;
33
34pub enum RadarLabel {
36 Text(SharedString),
39 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
69pub 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 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
138 self.id = id.into();
139 self
140 }
141
142 pub fn interactive(mut self, interactive: bool) -> Self {
151 self.interactive = interactive;
152 self
153 }
154
155 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
160 self.names.push(name.into());
161 self
162 }
163
164 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 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 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 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 pub fn value(mut self, value: impl Fn(&T) -> Y + 'static) -> Self {
217 self.values.push(Rc::new(value));
218 self
219 }
220
221 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
225 self.strokes.push(stroke.into());
226 self
227 }
228
229 pub fn fill(mut self, fill: impl Into<Background>) -> Self {
233 self.fills.push(fill.into());
234 self
235 }
236
237 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 pub fn label_color(mut self, color: impl Into<Hsla>) -> Self {
265 self.label_color = Some(color.into());
266 self
267 }
268
269 pub fn label_gap(mut self, gap: f32) -> Self {
272 self.label_gap = gap;
273 self
274 }
275
276 pub fn max_value(mut self, max_value: Y) -> Self {
280 self.max_value = Some(max_value);
281 self
282 }
283
284 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
288 self.outer_radius = outer_radius;
289 self
290 }
291
292 pub fn grid(mut self, grid: bool) -> Self {
296 self.grid = grid;
297 self
298 }
299
300 pub fn grid_levels(mut self, grid_levels: usize) -> Self {
302 self.grid_levels = grid_levels.max(1);
303 self
304 }
305
306 pub fn dot(mut self) -> Self {
308 self.dot = true;
309 self
310 }
311
312 fn series_stroke(&self, ix: usize, cx: &App) -> Hsla {
316 self.series_stroke_from(&Self::palette(cx), ix)
317 }
318
319 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 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 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 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 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 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 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 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 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 let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
448 let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds);
449
450 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 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 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 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 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 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 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 || self.label_texts.get(state.index).cloned().flatten(),
642 || {
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 #[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 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 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 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 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}