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
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: 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 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 hover: Option<RadarHover>,
102}
103
104struct RadarHover {
106 dots: Vec<Point<Pixels>>,
109 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
147 self.id = Some(id.into());
148 self
149 }
150
151 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
156 self.names.push(name.into());
157 self
158 }
159
160 pub fn value(mut self, value: impl Fn(&T) -> Y + 'static) -> Self {
165 self.values.push(Rc::new(value));
166 self
167 }
168
169 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
173 self.strokes.push(stroke.into());
174 self
175 }
176
177 pub fn fill(mut self, fill: impl Into<Background>) -> Self {
181 self.fills.push(fill.into());
182 self
183 }
184
185 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 pub fn label_color(mut self, color: impl Into<Hsla>) -> Self {
213 self.label_color = Some(color.into());
214 self
215 }
216
217 pub fn label_gap(mut self, gap: f32) -> Self {
220 self.label_gap = gap;
221 self
222 }
223
224 pub fn max_value(mut self, max_value: Y) -> Self {
228 self.max_value = Some(max_value);
229 self
230 }
231
232 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
236 self.outer_radius = outer_radius;
237 self
238 }
239
240 pub fn grid(mut self, grid: bool) -> Self {
244 self.grid = grid;
245 self
246 }
247
248 pub fn grid_levels(mut self, grid_levels: usize) -> Self {
250 self.grid_levels = grid_levels.max(1);
251 self
252 }
253
254 pub fn dot(mut self) -> Self {
256 self.dot = true;
257 self
258 }
259
260 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 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 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 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 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 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 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 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 let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
386 let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds);
387
388 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 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 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 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 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 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 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 let (dots, focus) = match self.hover.as_ref() {
601 Some(hover) => (&hover.dots, hover.focus),
602 None => (&state.dots, 1.),
603 };
604
605 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 if let Some(title) = self.label_texts.get(state.index).cloned().flatten() {
620 tooltip = tooltip.title(title);
621 }
622
623 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 #[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 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 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 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 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}