Skip to main content

liora_components/
pie_chart.rs

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}