1use crate::chart::{
2 ChartBoundsTracker, ChartHitPoint, ChartPalette, ChartSeries, ChartValueLabelContent,
3 ChartValueLabelOptions, ChartValueLabelPlacement, format_hit_tooltip, format_value_label,
4 has_chart_data,
5};
6use crate::chart_frame::paint_chart_label_aligned;
7use crate::gpui_compat::PixelsExt;
8use crate::{Empty, Space, Text};
9use gpui::{
10 App, Bounds, Component, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement,
11 Pixels, Point, RenderOnce, SharedString, Styled, Window, canvas, div, point, px, size,
12};
13use liora_core::{Config, Placement, TooltipData, clear_tooltip, set_active_tooltip, unique_id};
14use std::cell::Cell;
15use std::rc::Rc;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct PieChartLabelOptions {
19 pub value: ChartValueLabelOptions,
20}
21
22impl Default for PieChartLabelOptions {
23 fn default() -> Self {
24 Self {
25 value: ChartValueLabelOptions {
26 content: ChartValueLabelContent::ValueOverTotalAndPercentage,
27 placement: ChartValueLabelPlacement::Auto,
28 percentage_decimals: 1,
29 outside_threshold_degrees: 28,
30 },
31 }
32 }
33}
34
35#[derive(Clone)]
36pub struct PieChart {
37 slices: Vec<ChartSeries>,
38 id: SharedString,
39 height: Pixels,
40 show_legend: bool,
41 show_value_labels: bool,
42 label_options: PieChartLabelOptions,
43 show_tooltip: bool,
44 tooltip_hit_radius: Pixels,
45}
46
47#[derive(Clone)]
48pub struct RingChart {
49 slices: Vec<ChartSeries>,
50 id: SharedString,
51 height: Pixels,
52 show_legend: bool,
53 show_value_labels: bool,
54 label_options: PieChartLabelOptions,
55 show_tooltip: bool,
56 tooltip_hit_radius: Pixels,
57 inner_ratio: f32,
58 external_legend: Option<RingExternalLegendOptions>,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum RingExternalLegendLayout {
63 Vertical,
64 Horizontal,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum RingExternalLegendSide {
69 Left,
70 Right,
71}
72
73#[derive(Clone, Debug, PartialEq)]
74pub struct PieSliceHitRegion {
75 pub series_index: usize,
76 pub series_name: SharedString,
77 pub label: SharedString,
78 pub value: f64,
79 pub start_deg: f32,
80 pub end_deg: f32,
81 pub inner_radius: f32,
82 pub outer_radius: f32,
83}
84
85pub fn pie_slice_hit_regions(
86 slices: &[ChartSeries],
87 inner_ratio: f32,
88 outer_radius: f32,
89) -> Vec<PieSliceHitRegion> {
90 if slices.is_empty() || !outer_radius.is_finite() || outer_radius <= 0.0 {
91 return Vec::new();
92 }
93 let total = series_total(slices);
94 if total <= f64::EPSILON {
95 return Vec::new();
96 }
97
98 let inner_radius = (outer_radius * inner_ratio.clamp(0.0, 0.95)).max(0.0);
99 let mut start = -90.0_f32;
100 let mut regions = Vec::new();
101 for (series_index, series) in slices.iter().enumerate() {
102 let value = series_value(series).max(0.0);
103 if value <= 0.0 {
104 continue;
105 }
106 let sweep = (value / total) as f32 * 360.0;
107 let end = start + sweep;
108 let label = series
109 .finite_points()
110 .next()
111 .map(|point| point.label.clone())
112 .unwrap_or_else(|| series.name.clone());
113 regions.push(PieSliceHitRegion {
114 series_index,
115 series_name: series.name.clone(),
116 label,
117 value,
118 start_deg: start,
119 end_deg: end,
120 inner_radius,
121 outer_radius,
122 });
123 start = end;
124 }
125 regions
126}
127
128pub fn nearest_pie_slice_hit_point(
129 slices: &[ChartSeries],
130 inner_ratio: f32,
131 outer_radius: f32,
132 center_x: f32,
133 center_y: f32,
134 pointer_x: f32,
135 pointer_y: f32,
136 hit_radius: f32,
137) -> Option<ChartHitPoint> {
138 if !center_x.is_finite()
139 || !center_y.is_finite()
140 || !pointer_x.is_finite()
141 || !pointer_y.is_finite()
142 || !hit_radius.is_finite()
143 || hit_radius < 0.0
144 {
145 return None;
146 }
147
148 let dx = pointer_x - center_x;
149 let dy = pointer_y - center_y;
150 let radius = (dx * dx + dy * dy).sqrt();
151 let mut angle = dy.atan2(dx).to_degrees();
152 while angle < -90.0 {
153 angle += 360.0;
154 }
155 while angle >= 270.0 {
156 angle -= 360.0;
157 }
158
159 let mut best: Option<(PieSliceHitRegion, f32)> = None;
160 for region in pie_slice_hit_regions(slices, inner_ratio, outer_radius) {
161 if angle < region.start_deg || angle > region.end_deg {
162 continue;
163 }
164 let inner_distance = region.inner_radius - radius;
165 let outer_distance = radius - region.outer_radius;
166 let distance = if radius < region.inner_radius {
167 inner_distance
168 } else if radius > region.outer_radius {
169 outer_distance
170 } else {
171 0.0
172 };
173 if distance <= hit_radius
174 && best
175 .as_ref()
176 .is_none_or(|(_, best_distance)| distance < *best_distance)
177 {
178 best = Some((region, distance));
179 }
180 }
181
182 best.map(|(region, distance)| {
183 let mid_deg = (region.start_deg + region.end_deg) * 0.5;
184 let hit_radius = (region.inner_radius + region.outer_radius) * 0.5;
185 ChartHitPoint {
186 series_index: region.series_index,
187 point_index: 0,
188 series_name: region.series_name.clone(),
189 label: region.label.clone(),
190 value: region.value,
191 x: center_x + hit_radius * mid_deg.to_radians().cos(),
192 y: center_y + hit_radius * mid_deg.to_radians().sin(),
193 distance,
194 }
195 })
196}
197
198#[derive(Clone, Debug, PartialEq)]
199pub struct RingExternalLegendOptions {
200 layout: RingExternalLegendLayout,
201 side: RingExternalLegendSide,
202 content: ChartValueLabelContent,
203 percentage_decimals: usize,
204 max_items: Option<usize>,
205}
206
207impl Default for RingExternalLegendOptions {
208 fn default() -> Self {
209 Self {
210 layout: RingExternalLegendLayout::Vertical,
211 side: RingExternalLegendSide::Right,
212 content: ChartValueLabelContent::ValueOverTotalAndPercentage,
213 percentage_decimals: 1,
214 max_items: None,
215 }
216 }
217}
218
219impl PieChart {
220 pub fn new(slices: impl IntoIterator<Item = ChartSeries>) -> Self {
221 Self {
222 slices: slices.into_iter().collect(),
223 id: unique_id("pie-chart"),
224 height: px(280.0),
225 show_legend: true,
226 show_value_labels: true,
227 label_options: PieChartLabelOptions::default(),
228 show_tooltip: true,
229 tooltip_hit_radius: px(0.0),
230 }
231 }
232
233 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
234 self.id = id.into();
235 self
236 }
237
238 pub fn height(mut self, height: impl Into<Pixels>) -> Self {
239 self.height = height.into();
240 self
241 }
242
243 pub fn show_legend(mut self, show: bool) -> Self {
244 self.show_legend = show;
245 self
246 }
247
248 pub fn show_value_labels(mut self, show: bool) -> Self {
249 self.show_value_labels = show;
250 self
251 }
252
253 pub fn show_tooltip(mut self, show: bool) -> Self {
254 self.show_tooltip = show;
255 self
256 }
257
258 pub fn tooltip_hit_radius(mut self, radius: impl Into<Pixels>) -> Self {
259 self.tooltip_hit_radius = radius.into().max(px(0.0));
260 self
261 }
262
263 pub fn show_percentage_labels(mut self, show: bool) -> Self {
264 self.label_options.value.content = if show {
265 ChartValueLabelContent::ValueOverTotalAndPercentage
266 } else {
267 ChartValueLabelContent::ValueOverTotal
268 };
269 self
270 }
271
272 pub fn value_label_content(mut self, content: ChartValueLabelContent) -> Self {
273 self.label_options.value.content = content;
274 self
275 }
276
277 pub fn value_label_placement(mut self, placement: ChartValueLabelPlacement) -> Self {
278 self.label_options.value.placement = placement;
279 self
280 }
281
282 pub fn percentage_decimals(mut self, decimals: usize) -> Self {
283 self.label_options.value.percentage_decimals = decimals.min(4);
284 self
285 }
286
287 pub fn outside_label_threshold_degrees(mut self, degrees: u16) -> Self {
288 self.label_options.value.outside_threshold_degrees = degrees.min(120);
289 self
290 }
291
292 pub fn label_options(&self) -> &PieChartLabelOptions {
293 &self.label_options
294 }
295
296 pub fn slices(&self) -> &[ChartSeries] {
297 &self.slices
298 }
299}
300
301impl RingChart {
302 pub fn new(slices: impl IntoIterator<Item = ChartSeries>) -> Self {
303 Self {
304 slices: slices.into_iter().collect(),
305 id: unique_id("ring-chart"),
306 height: px(280.0),
307 show_legend: true,
308 show_value_labels: true,
309 label_options: PieChartLabelOptions::default(),
310 show_tooltip: true,
311 tooltip_hit_radius: px(0.0),
312 inner_ratio: 0.62,
313 external_legend: None,
314 }
315 }
316
317 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
318 self.id = id.into();
319 self
320 }
321
322 pub fn height(mut self, height: impl Into<Pixels>) -> Self {
323 self.height = height.into();
324 self
325 }
326
327 pub fn show_legend(mut self, show: bool) -> Self {
328 self.show_legend = show;
329 self
330 }
331
332 pub fn show_value_labels(mut self, show: bool) -> Self {
333 self.show_value_labels = show;
334 self
335 }
336
337 pub fn show_tooltip(mut self, show: bool) -> Self {
338 self.show_tooltip = show;
339 self
340 }
341
342 pub fn tooltip_hit_radius(mut self, radius: impl Into<Pixels>) -> Self {
343 self.tooltip_hit_radius = radius.into().max(px(0.0));
344 self
345 }
346
347 pub fn show_percentage_labels(mut self, show: bool) -> Self {
348 self.label_options.value.content = if show {
349 ChartValueLabelContent::ValueOverTotalAndPercentage
350 } else {
351 ChartValueLabelContent::ValueOverTotal
352 };
353 self
354 }
355
356 pub fn value_label_content(mut self, content: ChartValueLabelContent) -> Self {
357 self.label_options.value.content = content;
358 self
359 }
360
361 pub fn value_label_placement(mut self, placement: ChartValueLabelPlacement) -> Self {
362 self.label_options.value.placement = placement;
363 self
364 }
365
366 pub fn percentage_decimals(mut self, decimals: usize) -> Self {
367 self.label_options.value.percentage_decimals = decimals.min(4);
368 self
369 }
370
371 pub fn outside_label_threshold_degrees(mut self, degrees: u16) -> Self {
372 self.label_options.value.outside_threshold_degrees = degrees.min(120);
373 self
374 }
375
376 pub fn label_options(&self) -> &PieChartLabelOptions {
377 &self.label_options
378 }
379
380 pub fn inner_ratio(mut self, ratio: f32) -> Self {
381 self.inner_ratio = ratio.clamp(0.2, 0.9);
382 self
383 }
384
385 pub fn external_legend(mut self, options: RingExternalLegendOptions) -> Self {
386 self.external_legend = Some(options);
387 self.show_value_labels = false;
388 self.show_legend = false;
389 self
390 }
391
392 pub fn external_vertical_legend(self) -> Self {
393 self.external_legend(RingExternalLegendOptions::default())
394 }
395
396 pub fn external_horizontal_legend(self) -> Self {
397 self.external_legend(
398 RingExternalLegendOptions::default().layout(RingExternalLegendLayout::Horizontal),
399 )
400 }
401
402 pub fn external_legend_side(mut self, side: RingExternalLegendSide) -> Self {
403 let mut options = self.external_legend.unwrap_or_default();
404 options.side = side;
405 self.external_legend = Some(options);
406 self
407 }
408
409 pub fn external_legend_left(self) -> Self {
410 self.external_legend_side(RingExternalLegendSide::Left)
411 }
412
413 pub fn external_legend_right(self) -> Self {
414 self.external_legend_side(RingExternalLegendSide::Right)
415 }
416
417 pub fn external_legend_max_items(mut self, max_items: usize) -> Self {
418 let mut options = self.external_legend.unwrap_or_default();
419 options.max_items = Some(max_items.max(1));
420 self.external_legend = Some(options);
421 self
422 }
423
424 pub fn external_legend_content(mut self, content: ChartValueLabelContent) -> Self {
425 let mut options = self.external_legend.unwrap_or_default();
426 options.content = content;
427 self.external_legend = Some(options);
428 self
429 }
430
431 pub fn external_legend_percentage_decimals(mut self, decimals: usize) -> Self {
432 let mut options = self.external_legend.unwrap_or_default();
433 options.percentage_decimals = decimals.min(4);
434 self.external_legend = Some(options);
435 self
436 }
437
438 pub fn slices(&self) -> &[ChartSeries] {
439 &self.slices
440 }
441}
442
443impl RingExternalLegendOptions {
444 pub fn new() -> Self {
445 Self::default()
446 }
447
448 pub fn layout(mut self, layout: RingExternalLegendLayout) -> Self {
449 self.layout = layout;
450 self
451 }
452
453 pub fn vertical(self) -> Self {
454 self.layout(RingExternalLegendLayout::Vertical)
455 }
456
457 pub fn horizontal(self) -> Self {
458 self.layout(RingExternalLegendLayout::Horizontal)
459 }
460
461 pub fn side(mut self, side: RingExternalLegendSide) -> Self {
462 self.side = side;
463 self
464 }
465
466 pub fn left(self) -> Self {
467 self.side(RingExternalLegendSide::Left)
468 }
469
470 pub fn right(self) -> Self {
471 self.side(RingExternalLegendSide::Right)
472 }
473
474 pub fn content(mut self, content: ChartValueLabelContent) -> Self {
475 self.content = content;
476 self
477 }
478
479 pub fn percentage_decimals(mut self, decimals: usize) -> Self {
480 self.percentage_decimals = decimals.min(4);
481 self
482 }
483
484 pub fn max_items(mut self, max_items: usize) -> Self {
485 self.max_items = Some(max_items.max(1));
486 self
487 }
488}
489
490impl IntoElement for PieChart {
491 type Element = Component<Self>;
492
493 fn into_element(self) -> Self::Element {
494 Component::new(self)
495 }
496}
497
498impl IntoElement for RingChart {
499 type Element = Component<Self>;
500
501 fn into_element(self) -> Self::Element {
502 Component::new(self)
503 }
504}
505
506impl RenderOnce for PieChart {
507 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
508 render_shell(
509 self.slices,
510 self.id,
511 self.height,
512 self.show_legend,
513 self.show_value_labels,
514 self.label_options,
515 self.show_tooltip,
516 self.tooltip_hit_radius,
517 0.0,
518 None,
519 cx,
520 )
521 }
522}
523
524impl RenderOnce for RingChart {
525 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
526 render_shell(
527 self.slices,
528 self.id,
529 self.height,
530 self.show_legend,
531 self.show_value_labels,
532 self.label_options,
533 self.show_tooltip,
534 self.tooltip_hit_radius,
535 self.inner_ratio,
536 self.external_legend,
537 cx,
538 )
539 }
540}
541
542fn render_shell(
543 slices: Vec<ChartSeries>,
544 id: SharedString,
545 height: Pixels,
546 show_legend: bool,
547 show_value_labels: bool,
548 label_options: PieChartLabelOptions,
549 show_tooltip: bool,
550 tooltip_hit_radius: Pixels,
551 inner_ratio: f32,
552 external_legend: Option<RingExternalLegendOptions>,
553 cx: &mut App,
554) -> impl IntoElement {
555 let theme = cx.global::<Config>().theme.clone();
556 let palette = ChartPalette::from_config(cx.global::<Config>());
557 let has_data = has_chart_data(&slices);
558 let tooltip_id: SharedString = format!("{}-tooltip", id).into();
559
560 let mut shell = div()
561 .id(ElementId::from(id))
562 .flex()
563 .flex_col()
564 .gap_2()
565 .w_full()
566 .p_3()
567 .rounded_md()
568 .border_1()
569 .border_color(theme.neutral.border)
570 .bg(theme.neutral.card);
571
572 if !has_data {
573 return shell
574 .h(height)
575 .items_center()
576 .justify_center()
577 .child(Empty::new().description("暂无图表数据"))
578 .into_any_element();
579 }
580
581 if show_legend {
582 shell = shell.child(render_legend(&slices, &palette));
583 }
584
585 let side_legend = external_legend
586 .as_ref()
587 .is_some_and(|options| options.layout == RingExternalLegendLayout::Vertical);
588 let canvas_height = if side_legend { px(280.0) } else { height };
589 let canvas = render_canvas(
590 slices.clone(),
591 palette.clone(),
592 theme.neutral.card,
593 theme.neutral.inverted,
594 inner_ratio,
595 show_value_labels,
596 label_options,
597 tooltip_id,
598 show_tooltip,
599 tooltip_hit_radius,
600 canvas_height,
601 );
602
603 match external_legend {
604 Some(options) if options.layout == RingExternalLegendLayout::Vertical => {
605 let legend = render_external_legend(&slices, &palette, options.clone());
606 let content = div()
607 .flex()
608 .items_center()
609 .gap_2()
610 .children(match options.side {
611 RingExternalLegendSide::Left => vec![
612 legend.into_any_element(),
613 div()
614 .flex_none()
615 .w(canvas_height)
616 .child(canvas)
617 .into_any_element(),
618 ],
619 RingExternalLegendSide::Right => vec![
620 div()
621 .flex_none()
622 .w(canvas_height)
623 .child(canvas)
624 .into_any_element(),
625 legend.into_any_element(),
626 ],
627 });
628 shell.child(content).into_any_element()
629 }
630 Some(options) => shell
631 .child(canvas)
632 .child(render_external_legend(&slices, &palette, options))
633 .into_any_element(),
634 None => shell.child(canvas).into_any_element(),
635 }
636}
637
638fn render_legend(series: &[ChartSeries], palette: &ChartPalette) -> impl IntoElement {
639 Space::new()
640 .wrap()
641 .gap_md()
642 .children(series.iter().enumerate().map(|(index, series)| {
643 let color = series.color.unwrap_or_else(|| palette.series_color(index));
644 Space::new()
645 .gap_xs()
646 .align_center()
647 .child(div().w(px(10.0)).h(px(10.0)).rounded_sm().bg(color))
648 .child(Text::new(series.name.clone()).size(px(12.0)))
649 }))
650}
651
652fn render_external_legend(
653 series: &[ChartSeries],
654 palette: &ChartPalette,
655 options: RingExternalLegendOptions,
656) -> impl IntoElement {
657 let total = series_total(series);
658 let items = series
659 .iter()
660 .enumerate()
661 .take(options.max_items.unwrap_or(usize::MAX))
662 .filter_map(|(index, series)| {
663 let value = series_value(series).max(0.0);
664 (value > 0.0).then_some((index, series, value))
665 })
666 .map(|(index, series, value)| {
667 let color = series.color.unwrap_or_else(|| palette.series_color(index));
668 let text = format_value_label(
669 value,
670 total,
671 None,
672 &ChartValueLabelOptions {
673 content: options.content,
674 placement: ChartValueLabelPlacement::OutsideAligned,
675 percentage_decimals: options.percentage_decimals,
676 outside_threshold_degrees: 0,
677 },
678 );
679 div()
680 .flex()
681 .items_center()
682 .justify_between()
683 .gap_3()
684 .min_w(px(160.0))
685 .child(
686 Space::new()
687 .gap_xs()
688 .align_center()
689 .child(div().w(px(10.0)).h(px(10.0)).rounded_full().bg(color))
690 .child(Text::new(series.name.clone()).size(px(12.0))),
691 )
692 .child(Text::new(text).size(px(12.0)))
693 });
694
695 match options.layout {
696 RingExternalLegendLayout::Vertical => div()
697 .flex()
698 .flex_col()
699 .gap_2()
700 .flex_none()
701 .w(px(180.0))
702 .children(items),
703 RingExternalLegendLayout::Horizontal => div()
704 .flex()
705 .gap_2()
706 .w_full()
707 .flex_row()
708 .flex_wrap()
709 .gap_4()
710 .children(items),
711 }
712}
713
714fn render_canvas(
715 slices: Vec<ChartSeries>,
716 palette: ChartPalette,
717 hole_color: Hsla,
718 label_on_fill: Hsla,
719 inner_ratio: f32,
720 show_value_labels: bool,
721 label_options: PieChartLabelOptions,
722 tooltip_id: SharedString,
723 show_tooltip: bool,
724 tooltip_hit_radius: Pixels,
725 height: Pixels,
726) -> impl IntoElement {
727 let bounds_cell: Rc<Cell<Bounds<Pixels>>> = Rc::new(Cell::new(Bounds::default()));
728 let tooltip_bounds = bounds_cell.clone();
729 let tooltip_slices = slices.clone();
730 let move_id = tooltip_id.clone();
731 let chart = canvas(
732 |_, _, _| (),
733 move |bounds, _, window, cx| {
734 let inset = if show_value_labels {
735 px(56.0)
736 } else {
737 px(18.0)
738 };
739 let width = (bounds.right() - bounds.left() - inset * 2.0).max(px(1.0));
740 let height = (bounds.bottom() - bounds.top() - inset * 2.0).max(px(1.0));
741 let radius = (width.min(height).as_f32() / 2.0).max(1.0);
742 let center = point(
743 bounds.left() + width / 2.0 + inset,
744 bounds.top() + height / 2.0 + inset,
745 );
746
747 let values = slices
748 .iter()
749 .map(|series| series_value(series).max(0.0))
750 .collect::<Vec<_>>();
751 let total: f64 = values.iter().sum();
752 if total <= f64::EPSILON {
753 return;
754 }
755
756 let mut slice_labels = Vec::new();
757 let mut start = -90.0_f32;
758 for (index, (series, value)) in slices.iter().zip(values).enumerate() {
759 if value <= 0.0 {
760 continue;
761 }
762 let sweep = (value / total) as f32 * 360.0;
763 let color = series.color.unwrap_or_else(|| palette.series_color(index));
764 let end = start + sweep;
765 if let Some(path) = pie_slice_path(center, radius, start, end) {
766 window.paint_path(path, color);
767 }
768 slice_labels.push(SliceLabel {
769 start_deg: start,
770 end_deg: end,
771 value,
772 total,
773 color,
774 });
775 start = end;
776 }
777
778 if inner_ratio > 0.0 {
779 let hole_radius = (radius * inner_ratio).max(0.0);
780 if let Some(path) = circle_path(center, hole_radius) {
781 window.paint_path(path, hole_color);
782 }
783 }
784
785 if show_value_labels {
786 for label in slice_labels {
787 paint_slice_value_label(
788 center,
789 radius,
790 inner_ratio,
791 label,
792 &label_options,
793 &palette,
794 label_on_fill,
795 window,
796 cx,
797 );
798 }
799 }
800 },
801 )
802 .w_full()
803 .h(height);
804
805 div()
806 .relative()
807 .w_full()
808 .h(height)
809 .on_mouse_move(move |event, _, cx| {
810 if !show_tooltip {
811 clear_tooltip(&move_id, cx);
812 return;
813 }
814 let bounds = tooltip_bounds.get();
815 if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) {
816 clear_tooltip(&move_id, cx);
817 return;
818 }
819 let inset = if show_value_labels {
820 px(56.0)
821 } else {
822 px(18.0)
823 };
824 let width = (bounds.right() - bounds.left() - inset * 2.0).max(px(1.0));
825 let chart_height = (bounds.bottom() - bounds.top() - inset * 2.0).max(px(1.0));
826 let radius = (width.min(chart_height).as_f32() / 2.0).max(1.0);
827 let center = point(
828 bounds.left() + width / 2.0 + inset,
829 bounds.top() + chart_height / 2.0 + inset,
830 );
831 let Some(hit) = nearest_pie_slice_hit_point(
832 &tooltip_slices,
833 inner_ratio,
834 radius,
835 center.x.as_f32(),
836 center.y.as_f32(),
837 event.position.x.as_f32(),
838 event.position.y.as_f32(),
839 tooltip_hit_radius.as_f32(),
840 ) else {
841 clear_tooltip(&move_id, cx);
842 return;
843 };
844 set_active_tooltip(
845 TooltipData {
846 id: move_id.clone(),
847 content: format_hit_tooltip(&hit, None),
848 anchor_bounds: Bounds::new(
849 point(event.position.x - px(1.0), event.position.y - px(1.0)),
850 size(px(2.0), px(2.0)),
851 ),
852 placement: Placement::Top,
853 offset: px(8.0),
854 },
855 cx,
856 );
857 })
858 .child(ChartBoundsTracker::new(chart, bounds_cell))
859}
860
861fn series_value(series: &ChartSeries) -> f64 {
862 series
863 .finite_points()
864 .next()
865 .map(|point| point.value.max(0.0))
866 .unwrap_or(0.0)
867}
868
869fn series_total(series: &[ChartSeries]) -> f64 {
870 series.iter().map(series_value).sum()
871}
872
873#[derive(Clone, Copy)]
874struct SliceLabel {
875 start_deg: f32,
876 end_deg: f32,
877 value: f64,
878 total: f64,
879 color: Hsla,
880}
881
882fn paint_slice_value_label(
883 center: Point<Pixels>,
884 radius: f32,
885 inner_ratio: f32,
886 label: SliceLabel,
887 options: &PieChartLabelOptions,
888 palette: &ChartPalette,
889 label_on_fill: Hsla,
890 window: &mut Window,
891 cx: &mut App,
892) {
893 let sweep = (label.end_deg - label.start_deg).abs();
894 if sweep <= f32::EPSILON {
895 return;
896 }
897
898 let mid_deg = (label.start_deg + label.end_deg) * 0.5;
899 let text = format_slice_label(label.value, label.total, options);
900 let force_outside = matches!(
901 options.value.placement,
902 ChartValueLabelPlacement::OutsideFree | ChartValueLabelPlacement::OutsideAligned
903 );
904 if force_outside || sweep < options.value.outside_threshold_degrees as f32 {
905 paint_outside_slice_label(
906 center,
907 radius,
908 mid_deg,
909 text,
910 label.color,
911 palette,
912 options.value.placement,
913 window,
914 cx,
915 );
916 return;
917 }
918
919 let label_radius = if inner_ratio > 0.0 {
920 radius * (inner_ratio + 1.0) * 0.5
921 } else {
922 radius * 0.62
923 };
924 let position = polar_point(center, label_radius, mid_deg);
925 paint_chart_label_aligned(
926 text,
927 point(position.x - px(36.0), position.y - px(7.0)),
928 label_on_fill,
929 gpui::TextAlign::Center,
930 Some(px(72.0)),
931 window,
932 cx,
933 );
934}
935
936fn paint_outside_slice_label(
937 center: Point<Pixels>,
938 radius: f32,
939 mid_deg: f32,
940 text: SharedString,
941 color: Hsla,
942 palette: &ChartPalette,
943 placement: ChartValueLabelPlacement,
944 window: &mut Window,
945 cx: &mut App,
946) {
947 let edge = polar_point(center, radius, mid_deg);
948 let elbow = polar_point(center, radius + 14.0, mid_deg);
949 let right_side = mid_deg.to_radians().cos() >= 0.0;
950 let label_anchor = if placement == ChartValueLabelPlacement::OutsideAligned {
951 point(
952 center.x
953 + if right_side {
954 px(radius + 62.0)
955 } else {
956 px(-(radius + 62.0))
957 },
958 elbow.y,
959 )
960 } else {
961 point(
962 elbow.x + if right_side { px(34.0) } else { px(-34.0) },
963 elbow.y,
964 )
965 };
966
967 if let Some(path) = leader_line_path(edge, elbow, label_anchor) {
968 window.paint_path(path, color.opacity(0.82));
969 }
970
971 let (origin, align) = if right_side {
972 (
973 point(label_anchor.x + px(4.0), label_anchor.y - px(7.0)),
974 gpui::TextAlign::Left,
975 )
976 } else {
977 (
978 point(label_anchor.x - px(76.0), label_anchor.y - px(7.0)),
979 gpui::TextAlign::Right,
980 )
981 };
982 paint_chart_label_aligned(
983 text,
984 origin,
985 palette.label,
986 align,
987 Some(px(72.0)),
988 window,
989 cx,
990 );
991}
992
993fn leader_line_path(
994 edge: Point<Pixels>,
995 elbow: Point<Pixels>,
996 label_anchor: Point<Pixels>,
997) -> Option<gpui::Path<Pixels>> {
998 let mut builder = gpui::PathBuilder::stroke(px(1.2));
999 builder.move_to(edge);
1000 builder.line_to(elbow);
1001 builder.line_to(label_anchor);
1002 builder.build().ok()
1003}
1004
1005fn format_slice_label(value: f64, total: f64, options: &PieChartLabelOptions) -> SharedString {
1006 format_value_label(value, total, None, &options.value)
1007}
1008
1009fn pie_slice_path(
1010 center: Point<Pixels>,
1011 radius: f32,
1012 start_deg: f32,
1013 end_deg: f32,
1014) -> Option<gpui::Path<Pixels>> {
1015 if radius <= 0.0 || !radius.is_finite() || !start_deg.is_finite() || !end_deg.is_finite() {
1016 return None;
1017 }
1018
1019 let sweep_deg = end_deg - start_deg;
1020 if sweep_deg.abs() >= 359.999 {
1021 return circle_path(center, radius);
1022 }
1023
1024 let start = polar_point(center, radius, start_deg);
1025 let mut builder = gpui::PathBuilder::fill();
1026 builder.move_to(center);
1027 builder.line_to(start);
1028 append_arc(&mut builder, center, radius, start_deg, end_deg);
1029 builder.line_to(center);
1030 builder.close();
1031 Some(builder.build().ok()?)
1032}
1033
1034fn circle_path(center: Point<Pixels>, radius: f32) -> Option<gpui::Path<Pixels>> {
1035 if radius <= 0.0 || !radius.is_finite() {
1036 return None;
1037 }
1038
1039 let start = polar_point(center, radius, -90.0);
1040 let mid = polar_point(center, radius, 90.0);
1041 let mut builder = gpui::PathBuilder::fill();
1042 builder.move_to(start);
1043 builder.arc_to(point(px(radius), px(radius)), px(0.0), false, true, mid);
1044 builder.arc_to(point(px(radius), px(radius)), px(0.0), false, true, start);
1045 builder.close();
1046 builder.build().ok()
1047}
1048
1049fn append_arc(
1050 builder: &mut gpui::PathBuilder,
1051 center: Point<Pixels>,
1052 radius: f32,
1053 start_deg: f32,
1054 end_deg: f32,
1055) {
1056 let sweep_deg = end_deg - start_deg;
1057 let large_arc = sweep_deg.abs() > 180.0;
1058 let sweep = sweep_deg >= 0.0;
1059 let end = polar_point(center, radius, end_deg);
1060 builder.arc_to(
1061 point(px(radius), px(radius)),
1062 px(0.0),
1063 large_arc,
1064 sweep,
1065 end,
1066 );
1067}
1068
1069fn polar_point(center: Point<Pixels>, radius: f32, deg: f32) -> Point<Pixels> {
1070 let radians = deg.to_radians();
1071 point(
1072 center.x + px(radius * radians.cos()),
1073 center.y + px(radius * radians.sin()),
1074 )
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080 use crate::chart::ChartPoint;
1081
1082 fn slices() -> Vec<ChartSeries> {
1083 vec![
1084 ChartSeries::new("A", [ChartPoint::new("A", 30.0)]),
1085 ChartSeries::new("B", [ChartPoint::new("B", 20.0)]),
1086 ChartSeries::new("C", [ChartPoint::new("C", 50.0)]),
1087 ]
1088 }
1089
1090 #[test]
1091 fn pie_chart_tracks_slices() {
1092 let chart = PieChart::new(slices())
1093 .id("pie")
1094 .height(px(240.0))
1095 .show_legend(false)
1096 .show_value_labels(false)
1097 .show_tooltip(false)
1098 .tooltip_hit_radius(px(6.0))
1099 .show_percentage_labels(false)
1100 .percentage_decimals(2)
1101 .outside_label_threshold_degrees(36);
1102 assert_eq!(chart.slices().len(), 3);
1103 assert!(!chart.show_value_labels);
1104 assert!(!chart.show_tooltip);
1105 assert_eq!(chart.tooltip_hit_radius, px(6.0));
1106 assert!(!matches!(
1107 chart.label_options().value.content,
1108 ChartValueLabelContent::ValueOverTotalAndPercentage
1109 ));
1110 assert_eq!(chart.label_options().value.percentage_decimals, 2);
1111 assert_eq!(chart.label_options().value.outside_threshold_degrees, 36);
1112 }
1113
1114 #[test]
1115 fn ring_chart_tracks_inner_ratio() {
1116 let chart = RingChart::new(slices())
1117 .inner_ratio(0.5)
1118 .show_value_labels(false)
1119 .show_tooltip(false)
1120 .tooltip_hit_radius(px(8.0))
1121 .percentage_decimals(3);
1122 assert_eq!(chart.slices().len(), 3);
1123 assert!(chart.inner_ratio >= 0.2 && chart.inner_ratio <= 0.9);
1124 assert!(!chart.show_value_labels);
1125 assert!(!chart.show_tooltip);
1126 assert_eq!(chart.tooltip_hit_radius, px(8.0));
1127 assert_eq!(chart.label_options().value.percentage_decimals, 3);
1128 }
1129
1130 #[test]
1131 fn ring_chart_external_legend_disables_inline_labels() {
1132 let chart = RingChart::new(slices())
1133 .external_horizontal_legend()
1134 .external_legend_content(ChartValueLabelContent::Percentage)
1135 .external_legend_percentage_decimals(2);
1136 assert!(!chart.show_legend);
1137 assert!(!chart.show_value_labels);
1138 let options = chart.external_legend.unwrap();
1139 assert_eq!(options.layout, RingExternalLegendLayout::Horizontal);
1140 assert_eq!(options.side, RingExternalLegendSide::Right);
1141 assert_eq!(options.content, ChartValueLabelContent::Percentage);
1142 assert_eq!(options.percentage_decimals, 2);
1143 }
1144
1145 #[test]
1146 fn ring_chart_external_legend_tracks_side_and_limit() {
1147 let chart = RingChart::new(slices())
1148 .external_vertical_legend()
1149 .external_legend_left()
1150 .external_legend_max_items(2);
1151 let options = chart.external_legend.unwrap();
1152 assert_eq!(options.layout, RingExternalLegendLayout::Vertical);
1153 assert_eq!(options.side, RingExternalLegendSide::Left);
1154 assert_eq!(options.max_items, Some(2));
1155 }
1156
1157 #[test]
1158 fn slice_labels_use_value_total_and_configurable_percentage_precision() {
1159 let options = PieChartLabelOptions {
1160 value: ChartValueLabelOptions {
1161 percentage_decimals: 2,
1162 ..PieChartLabelOptions::default().value
1163 },
1164 };
1165 assert_eq!(
1166 format_slice_label(1.0, 3.0, &options),
1167 SharedString::from("1 / 3 (33.33%)")
1168 );
1169
1170 let options = PieChartLabelOptions {
1171 value: ChartValueLabelOptions {
1172 content: ChartValueLabelContent::ValueOverTotal,
1173 ..PieChartLabelOptions::default().value
1174 },
1175 };
1176 assert_eq!(
1177 format_slice_label(1.0, 3.0, &options),
1178 SharedString::from("1 / 3")
1179 );
1180 }
1181
1182 #[test]
1183 fn pie_slice_hit_testing_returns_slice_under_pointer() {
1184 let regions = pie_slice_hit_regions(&slices(), 0.0, 100.0);
1185 assert_eq!(regions.len(), 3);
1186 assert_eq!(regions[0].series_name, SharedString::from("A"));
1187 assert_eq!(regions[0].start_deg, -90.0);
1188 assert!((regions[0].end_deg - 18.0).abs() < 0.001);
1189
1190 let hit = nearest_pie_slice_hit_point(&slices(), 0.0, 100.0, 0.0, 0.0, 70.0, -70.0, 0.0)
1191 .expect("pointer inside first slice should hit");
1192 assert_eq!(hit.series_index, 0);
1193 assert_eq!(hit.series_name, SharedString::from("A"));
1194 assert_eq!(hit.label, SharedString::from("A"));
1195 assert_eq!(hit.value, 30.0);
1196 }
1197
1198 #[test]
1199 fn ring_slice_hit_testing_excludes_inner_hole_and_hits_ring_segment() {
1200 assert_eq!(
1201 nearest_pie_slice_hit_point(&slices(), 0.62, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0),
1202 None
1203 );
1204
1205 let hit = nearest_pie_slice_hit_point(&slices(), 0.62, 100.0, 0.0, 0.0, 70.0, -70.0, 0.0)
1206 .expect("pointer inside ring segment should hit");
1207 assert_eq!(hit.series_index, 0);
1208 }
1209}