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
30const DEFAULT_LABEL_GAP: f32 = 10.;
32
33const DEFAULT_GRID_LEVELS: usize = 4;
35
36pub enum RadarLabel {
38 Text(SharedString),
41 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#[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 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
143 self.id = id.into();
144 self
145 }
146
147 pub fn interactive(mut self, interactive: bool) -> Self {
155 self.interactive = interactive;
156 self
157 }
158
159 pub fn appear(mut self, appear: bool) -> Self {
166 self.appear.set_enabled(appear);
167 self
168 }
169
170 pub fn appear_key(mut self, key: impl Hash) -> Self {
175 self.appear.set_key(key);
176 self
177 }
178
179 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
184 self.names.push(name.into());
185 self
186 }
187
188 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 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 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 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 pub fn value(mut self, value: impl Fn(&T) -> Y + 'static) -> Self {
241 self.values.push(Rc::new(value));
242 self
243 }
244
245 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
249 self.strokes.push(stroke.into());
250 self
251 }
252
253 pub fn fill(mut self, fill: impl Into<Background>) -> Self {
257 self.fills.push(fill.into());
258 self
259 }
260
261 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 pub fn label_color(mut self, color: impl Into<Hsla>) -> Self {
289 self.label_color = Some(color.into());
290 self
291 }
292
293 pub fn label_gap(mut self, gap: f32) -> Self {
296 self.label_gap = gap;
297 self
298 }
299
300 pub fn max_value(mut self, max_value: Y) -> Self {
304 self.max_value = Some(max_value);
305 self
306 }
307
308 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
312 self.outer_radius = outer_radius;
313 self
314 }
315
316 pub fn grid(mut self, grid: bool) -> Self {
320 self.grid = grid;
321 self
322 }
323
324 pub fn grid_levels(mut self, grid_levels: usize) -> Self {
326 self.grid_levels = grid_levels.max(1);
327 self
328 }
329
330 pub fn dot(mut self) -> Self {
332 self.dot = true;
333 self
334 }
335
336 fn series_stroke(&self, ix: usize, cx: &App) -> Hsla {
340 self.series_stroke_from(&Self::palette(cx), ix)
341 }
342
343 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 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 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 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 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 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 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 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 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 let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
470 let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds);
471
472 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 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 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 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 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 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 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 || self.label_texts.get(state.index).cloned().flatten(),
677 || {
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 #[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 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 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 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 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}