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_component_macros::IntoPlot;
11use num_traits::{Num, ToPrimitive, Zero};
12
13use crate::{
14 ActiveTheme,
15 plot::{
16 Plot,
17 label::{PlotLabel, TEXT_SIZE, Text},
18 polygon,
19 scale::{Scale, ScaleLinear, Sealed},
20 shape::RadialLine,
21 tooltip::{Dot, Tooltip, TooltipState},
22 },
23};
24
25const HALF_PI: f32 = PI / 2.;
26
27const DEFAULT_LABEL_GAP: f32 = 10.;
29
30const DEFAULT_GRID_LEVELS: usize = 4;
32
33pub enum RadarLabel {
35 Text(SharedString),
38 Element(AnyElement),
42}
43
44impl From<&'static str> for RadarLabel {
45 fn from(text: &'static str) -> Self {
46 Self::Text(text.into())
47 }
48}
49
50impl From<String> for RadarLabel {
51 fn from(text: String) -> Self {
52 Self::Text(text.into())
53 }
54}
55
56impl From<SharedString> for RadarLabel {
57 fn from(text: SharedString) -> Self {
58 Self::Text(text)
59 }
60}
61
62impl From<AnyElement> for RadarLabel {
63 fn from(element: AnyElement) -> Self {
64 Self::Element(element)
65 }
66}
67
68#[derive(IntoPlot)]
74pub struct RadarChart<T, Y>
75where
76 T: 'static,
77 Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
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 label: Option<Rc<dyn Fn(&T) -> RadarLabel + 'static>>,
85 label_texts: Vec<Option<SharedString>>,
89 label_color: Option<Hsla>,
90 label_gap: f32,
91 max_value: Option<Y>,
92 outer_radius: f32,
93 grid: bool,
94 grid_levels: usize,
95 dot: bool,
96 id: Option<ElementId>,
97}
98
99impl<T, Y> RadarChart<T, Y>
100where
101 Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
102{
103 pub fn new<I>(data: I) -> Self
104 where
105 I: IntoIterator<Item = T>,
106 {
107 Self {
108 data: data.into_iter().collect(),
109 values: vec![],
110 strokes: vec![],
111 fills: vec![],
112 names: vec![],
113 label: None,
114 label_texts: vec![],
115 label_color: None,
116 label_gap: DEFAULT_LABEL_GAP,
117 max_value: None,
118 outer_radius: 0.,
119 grid: true,
120 grid_levels: DEFAULT_GRID_LEVELS,
121 dot: false,
122 id: None,
123 }
124 }
125
126 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
132 self.id = Some(id.into());
133 self
134 }
135
136 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
141 self.names.push(name.into());
142 self
143 }
144
145 pub fn value(mut self, value: impl Fn(&T) -> Y + 'static) -> Self {
150 self.values.push(Rc::new(value));
151 self
152 }
153
154 pub fn stroke(mut self, stroke: impl Into<Hsla>) -> Self {
158 self.strokes.push(stroke.into());
159 self
160 }
161
162 pub fn fill(mut self, fill: impl Into<Background>) -> Self {
166 self.fills.push(fill.into());
167 self
168 }
169
170 pub fn label<L>(mut self, label: impl Fn(&T) -> L + 'static) -> Self
187 where
188 L: Into<RadarLabel> + 'static,
189 {
190 self.label = Some(Rc::new(move |d| label(d).into()));
191 self
192 }
193
194 pub fn label_color(mut self, color: impl Into<Hsla>) -> Self {
198 self.label_color = Some(color.into());
199 self
200 }
201
202 pub fn label_gap(mut self, gap: f32) -> Self {
205 self.label_gap = gap;
206 self
207 }
208
209 pub fn max_value(mut self, max_value: Y) -> Self {
213 self.max_value = Some(max_value);
214 self
215 }
216
217 pub fn outer_radius(mut self, outer_radius: f32) -> Self {
221 self.outer_radius = outer_radius;
222 self
223 }
224
225 pub fn grid(mut self, grid: bool) -> Self {
229 self.grid = grid;
230 self
231 }
232
233 pub fn grid_levels(mut self, grid_levels: usize) -> Self {
235 self.grid_levels = grid_levels.max(1);
236 self
237 }
238
239 pub fn dot(mut self) -> Self {
241 self.dot = true;
242 self
243 }
244
245 fn series_stroke(&self, ix: usize, cx: &App) -> Hsla {
249 let colors = [
250 cx.theme().chart_1,
251 cx.theme().chart_2,
252 cx.theme().chart_3,
253 cx.theme().chart_4,
254 cx.theme().chart_5,
255 ];
256
257 self.strokes
258 .get(ix)
259 .copied()
260 .unwrap_or(colors[ix % colors.len()])
261 }
262
263 fn resolve_outer_radius(&self, bounds: &Bounds<Pixels>) -> f32 {
265 if self.outer_radius.is_zero() {
266 bounds.size.height.as_f32() * 0.4
267 } else {
268 self.outer_radius
269 }
270 }
271
272 fn label_anchor(
279 &self,
280 ix: usize,
281 outer_radius: f32,
282 bounds: &Bounds<Pixels>,
283 ) -> (Point<f32>, Point<f32>) {
284 let label_radius = outer_radius + self.label_gap;
285 let angle = ix as f32 * TAU / self.data.len() as f32 - HALF_PI;
286 let direction = point(angle.cos(), angle.sin());
287
288 let anchor = point(
289 bounds.size.width.as_f32() / 2. + label_radius * direction.x,
290 bounds.size.height.as_f32() / 2. + label_radius * direction.y,
291 );
292
293 (anchor, direction)
294 }
295
296 fn scale(&self, outer_radius: f32) -> ScaleLinear<Y> {
301 let domain = if let Some(max_value) = self.max_value {
302 vec![Y::zero(), max_value]
303 } else {
304 self.data
305 .iter()
306 .flat_map(|d| self.values.iter().map(|value_fn| value_fn(d)))
307 .chain(Some(Y::zero()))
308 .collect()
309 };
310
311 ScaleLinear::new(domain, vec![0., outer_radius])
312 }
313
314 fn hovered_index(&self, position: Point<Pixels>, bounds: Bounds<Pixels>) -> Option<usize> {
317 let n = self.data.len();
318 if n == 0 {
319 return None;
320 }
321
322 let outer_radius = self.resolve_outer_radius(&bounds);
323 let dx = position.x.as_f32() - bounds.size.width.as_f32() / 2.;
324 let dy = position.y.as_f32() - bounds.size.height.as_f32() / 2.;
325 if dx.hypot(dy) > outer_radius + self.label_gap {
326 return None;
327 }
328
329 let angle = (dy.atan2(dx) + HALF_PI).rem_euclid(TAU);
331 Some((angle * n as f32 / TAU).round() as usize % n)
332 }
333}
334
335impl<T, Y> Plot for RadarChart<T, Y>
336where
337 Y: Clone + Copy + PartialOrd + Num + ToPrimitive + Sealed + 'static,
338{
339 fn prepaint(
342 &mut self,
343 bounds: Bounds<Pixels>,
344 window: &mut Window,
345 cx: &mut App,
346 ) -> Vec<AnyElement> {
347 self.label_texts.clear();
348
349 let n = self.data.len();
351 if n == 0 || self.values.is_empty() {
352 return vec![];
353 }
354 let Some(label_fn) = self.label.clone() else {
355 return vec![];
356 };
357
358 let outer_radius = self.resolve_outer_radius(&bounds);
359 let mut texts = Vec::with_capacity(n);
360 let mut elements = vec![];
361
362 for (ix, d) in self.data.iter().enumerate() {
363 match label_fn(d) {
364 RadarLabel::Text(text) => texts.push(Some(text)),
365 RadarLabel::Element(mut element) => {
366 texts.push(None);
367
368 let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
371 let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds);
372
373 let origin = bounds.origin
378 + point(
379 px(anchor.x + (direction.x - 1.) * size.width.as_f32() / 2.),
380 px(anchor.y + (direction.y - 1.) * size.height.as_f32() / 2.),
381 );
382
383 element.prepaint_at(origin, window, cx);
384 elements.push(element);
385 }
386 }
387 }
388
389 self.label_texts = texts;
390
391 elements
392 }
393
394 fn paint(&mut self, bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) {
395 let n = self.data.len();
396 if n == 0 || self.values.is_empty() {
397 return;
398 }
399
400 let outer_radius = self.resolve_outer_radius(&bounds);
401 let angle_step = TAU / n as f32;
402 let center_x = bounds.size.width.as_f32() / 2.;
403 let center_y = bounds.size.height.as_f32() / 2.;
404 let scale = self.scale(outer_radius);
405
406 if self.grid {
408 let stroke = cx.theme().border;
409
410 for level in 1..=self.grid_levels {
411 let radius = outer_radius * level as f32 / self.grid_levels as f32;
412 RadialLine::new()
413 .data(0..n)
414 .angle(move |_, i| Some(i as f32 * angle_step))
415 .radius(move |_, _| Some(radius))
416 .closed()
417 .stroke(stroke)
418 .paint(&bounds, window);
419 }
420
421 for i in 0..n {
422 let angle = i as f32 * angle_step - HALF_PI;
423 let points = [
424 point(center_x, center_y),
425 point(
426 center_x + outer_radius * angle.cos(),
427 center_y + outer_radius * angle.sin(),
428 ),
429 ];
430 if let Some(path) = polygon(&points, &bounds) {
431 window.paint_path(path, stroke);
432 }
433 }
434 }
435
436 for (i, value_fn) in self.values.iter().enumerate() {
438 let stroke = self.series_stroke(i, cx);
439 let fill = self
440 .fills
441 .get(i)
442 .copied()
443 .unwrap_or_else(|| stroke.opacity(0.3).into());
444
445 let scale = scale.clone();
446 let value_fn = value_fn.clone();
447 let mut line = RadialLine::new()
448 .data(&self.data)
449 .angle(move |_, i| Some(i as f32 * angle_step))
450 .radius(move |d, _| scale.tick(&value_fn(d)))
451 .closed()
452 .fill(fill)
453 .stroke(stroke)
454 .stroke_width(2.);
455 if self.dot {
456 line = line.dot().dot_size(8.).dot_fill_color(stroke);
457 }
458 line.paint(&bounds, window);
459 }
460
461 let label_color = self.label_color.unwrap_or(cx.theme().muted_foreground);
464 let labels = self
465 .label_texts
466 .iter()
467 .enumerate()
468 .filter_map(|(ix, text)| {
469 let text = text.clone()?;
470 let (anchor, direction) = self.label_anchor(ix, outer_radius, &bounds);
471
472 let align = if direction.x > 1e-3 {
476 TextAlign::Left
477 } else if direction.x < -1e-3 {
478 TextAlign::Right
479 } else {
480 TextAlign::Center
481 };
482
483 Some(
484 Text::new(
485 text,
486 point(px(anchor.x), px(anchor.y - TEXT_SIZE / 2.)),
487 label_color,
488 )
489 .align(align),
490 )
491 });
492
493 PlotLabel::new(labels.collect()).paint(&bounds, window, cx);
494 }
495
496 fn id(&self) -> Option<ElementId> {
497 self.id.clone()
498 }
499
500 fn tooltip_state(
501 &self,
502 position: Point<Pixels>,
503 bounds: Bounds<Pixels>,
504 _cx: &App,
505 ) -> Option<TooltipState> {
506 if self.values.is_empty() {
507 return None;
508 }
509 let index = self.hovered_index(position, bounds)?;
510 let d = self.data.get(index)?;
511
512 let outer_radius = self.resolve_outer_radius(&bounds);
513 let scale = self.scale(outer_radius);
514 let center_x = bounds.size.width.as_f32() / 2.;
515 let center_y = bounds.size.height.as_f32() / 2.;
516 let angle = index as f32 * TAU / self.data.len() as f32 - HALF_PI;
517
518 let dots = self
520 .values
521 .iter()
522 .filter_map(|value_fn| {
523 let radius = scale.tick(&value_fn(d))?;
524 Some(point(
525 px(center_x + radius * angle.cos()),
526 px(center_y + radius * angle.sin()),
527 ))
528 })
529 .collect();
530
531 Some(TooltipState::new(index, position, dots))
532 }
533
534 fn tooltip(
535 &self,
536 state: &TooltipState,
537 cursor: Point<Pixels>,
538 bounds: Bounds<Pixels>,
539 _window: &mut Window,
540 cx: &mut App,
541 ) -> Option<AnyElement> {
542 let d = self.data.get(state.index)?;
543
544 let dot_stroke = cx.theme().background;
545
546 let mut tooltip =
549 Tooltip::new(cursor, bounds.size)
550 .gap(px(8.))
551 .dots(state.dots.iter().enumerate().map(|(i, p)| {
552 Dot::new(*p)
553 .stroke(dot_stroke)
554 .fill(self.series_stroke(i, cx))
555 }));
556
557 if let Some(title) = self.label_texts.get(state.index).cloned().flatten() {
559 tooltip = tooltip.title(title);
560 }
561
562 for (i, value_fn) in self.values.iter().enumerate() {
564 let name = self.names.get(i).cloned().unwrap_or_default();
565 let value = value_fn(d).to_f64()?;
566 tooltip = tooltip.row(self.series_stroke(i, cx), name, format!("{}", value));
567 }
568
569 Some(tooltip.into_any_element())
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[derive(Clone)]
578 struct Item {
579 subject: SharedString,
580 a: f64,
581 b: f64,
582 }
583
584 #[test]
585 fn test_radar_chart_builder() {
586 let data = vec![
587 Item {
588 subject: "Sales".into(),
589 a: 80.,
590 b: 60.,
591 },
592 Item {
593 subject: "Marketing".into(),
594 a: 50.,
595 b: 90.,
596 },
597 ];
598
599 let chart = RadarChart::new(data.clone())
600 .label(|d| d.subject.clone())
601 .value(|d| d.a)
602 .stroke(gpui::red())
603 .fill(gpui::red())
604 .name("A")
605 .value(|d| d.b)
606 .max_value(100.)
607 .outer_radius(120.)
608 .label_gap(8.)
609 .grid(false)
610 .grid_levels(5)
611 .dot()
612 .id("radar");
613
614 assert_eq!(chart.data.len(), 2);
615 assert_eq!(chart.values.len(), 2);
616 assert_eq!(chart.strokes.len(), 1);
617 assert_eq!(chart.fills.len(), 1);
618 assert_eq!(chart.names.len(), 1);
619 assert!(chart.label.is_some());
620 assert_eq!(chart.max_value, Some(100.));
621 assert_eq!(chart.outer_radius, 120.);
622 assert_eq!(chart.label_gap, 8.);
623 assert!(!chart.grid);
624 assert_eq!(chart.grid_levels, 5);
625 assert!(chart.dot);
626 assert!(chart.id.is_some());
627
628 let values = (chart.values[0](&data[0]), chart.values[1](&data[0]));
629 assert_eq!(values, (80., 60.));
630 }
631
632 #[test]
635 fn test_radar_label_from_text() {
636 let labels = [
637 RadarLabel::from("Sales"),
638 RadarLabel::from("Sales".to_string()),
639 RadarLabel::from(SharedString::from("Sales")),
640 ];
641
642 for label in labels {
643 assert!(matches!(label, RadarLabel::Text(text) if text == "Sales"));
644 }
645 }
646
647 #[test]
648 fn test_radar_chart_grid_levels_min() {
649 let chart: RadarChart<Item, f64> = RadarChart::new(vec![]).grid_levels(0);
650 assert_eq!(chart.grid_levels, 1);
651 }
652
653 #[test]
654 fn test_radar_chart_hovered_index() {
655 let data = (0..4)
656 .map(|i| Item {
657 subject: format!("S{}", i).into(),
658 a: 50.,
659 b: 50.,
660 })
661 .collect::<Vec<_>>();
662
663 let chart: RadarChart<Item, f64> = RadarChart::new(data).value(|d| d.a);
666 let bounds = gpui::Bounds::new(point(px(0.), px(0.)), gpui::size(px(200.), px(200.)));
667
668 assert_eq!(
670 chart.hovered_index(point(px(100.), px(30.)), bounds),
671 Some(0)
672 );
673 assert_eq!(
674 chart.hovered_index(point(px(170.), px(100.)), bounds),
675 Some(1)
676 );
677 assert_eq!(
678 chart.hovered_index(point(px(100.), px(170.)), bounds),
679 Some(2)
680 );
681 assert_eq!(
682 chart.hovered_index(point(px(30.), px(100.)), bounds),
683 Some(3)
684 );
685
686 assert_eq!(
688 chart.hovered_index(point(px(110.), px(40.)), bounds),
689 Some(0)
690 );
691 assert_eq!(
692 chart.hovered_index(point(px(160.), px(90.)), bounds),
693 Some(1)
694 );
695
696 assert_eq!(chart.hovered_index(point(px(100.), px(5.)), bounds), None);
698 assert_eq!(chart.hovered_index(point(px(5.), px(5.)), bounds), None);
699 }
700}