dioxus_charts/charts/
pie.rs

1use dioxus::prelude::*;
2
3use crate::types::{Labels, Point};
4use crate::utils::{normalize_series, polar_to_cartesian};
5
6/// A hint for the automatic positioning of labels in the pie chart.
7#[derive(Clone, Copy, PartialEq, Eq)]
8pub enum LabelPosition {
9    /// To position the label inside the pie chart.
10    Inside,
11    /// To position the label outside close to the border of the pie chart.
12    Outside,
13    /// To position the label in the center for manually positioning with the `label_offset` prop.
14    Center,
15}
16
17/// The `PieChart` properties struct for the configuration of the pie chart.
18#[derive(Clone, PartialEq, Props)]
19pub struct PieChartProps {
20    series: Vec<f32>,
21    #[props(optional)]
22    labels: Option<Labels>,
23
24    #[props(default = "100%".to_string(), into)]
25    width: String,
26    #[props(default = "100%".to_string(), into)]
27    height: String,
28    #[props(default = 600)]
29    viewbox_width: i32,
30    #[props(default = 400)]
31    viewbox_height: i32,
32
33    #[props(default = true)]
34    show_labels: bool,
35    #[props(default=LabelPosition::Inside)]
36    label_position: LabelPosition,
37    #[props(default)]
38    label_offset: f32,
39    #[props(optional)]
40    label_interpolation: Option<fn(f32) -> String>,
41
42    #[props(default)]
43    start_angle: f32,
44    #[props(optional)]
45    total: Option<f32>,
46    #[props(optional)]
47    show_ratio: Option<f32>,
48    #[props(default)]
49    padding: f32,
50
51    #[props(default = false)]
52    donut: bool,
53    #[props(default = 40.0)]
54    donut_width: f32,
55
56    #[props(default = "dx-pie-chart".to_string(), into)]
57    class_chart: String,
58    #[props(default = "dx-series".to_string(), into)]
59    class_series: String,
60    #[props(default = "dx-slice".to_string(), into)]
61    class_slice: String,
62    #[props(default = "dx-label".to_string(), into)]
63    class_label: String,
64}
65
66/// This is the `PieChart` function used to render the pie chart `Element`.
67/// In Dioxus, components are just functions, so this is the main `PieChart`
68/// component to be used inside `rsx!` macros in your code.
69///
70/// # Example
71///
72/// ```rust,ignore
73/// use dioxus::prelude::*;
74/// use dioxus_charts::PieChart;
75///
76/// fn app() -> Element {
77///     rsx! {
78///         PieChart {
79///             start_angle: -60.0,
80///             label_position: LabelPosition::Outside,
81///             label_offset: 35.0,
82///             padding: 20.0,
83///             series: vec![59.54, 17.2, 9.59, 7.6, 5.53, 0.55]
84///             labels: vec!["Asia".into(), "Africa".into(), "Europe".into(), "N. America".into(), "S. America".into(), "Oceania".into()],
85///         }
86///     }
87/// }
88/// ```
89///
90/// # Props
91///
92/// - `series`: [Vec]<[f32]> (**required**): The series vector with the values.
93/// - `labels`: [Vec]<[String]> (optional): Optional labels to show for each value of the
94/// series.
95/// ---
96/// - `width`: &[str] (default: `"100%"`): The SVG element width attribute. It also accepts any
97/// other CSS style, i.e., "200px"
98/// - `height`: &[str] (default: `"100%"`): The SVG height counter-part of the `width` prop above.
99/// - `viewbox_width`: [i32] (default: `600`): The SVG viewbox width. Together with
100/// `viewbox_height` it is useful scaling up or down the chart and labels.
101/// - `viewbox_height`: [i32] (default: `400`): The SVG viewbox height.
102/// ---
103/// - `show_labels`: [bool] (default: `true`): Show/hide labels.
104/// - `label_position`: [`LabelPosition`] (default: [`LabelPosition::Inside`]): A hint for the
105/// automatic positioning of labels on the chart.
106/// - `label_offset`: [f32] (default: `0.0`): An extra offset for the labels relative to the center
107/// of the pie.
108/// - `label_interpolation`: fn([f32]) -> [String] (optional): Function for formatting the
109/// generated labels.
110/// ---
111/// - `start_angle`: [f32] (default: `0.0`): The initial angle used for drawing the pie.
112/// - `total`: [f32] (optional): The series total sum. Can be used to make Gauge charts.
113/// - `show_ratio`: [f32] (optional): Used for making Gauge charts more easily. `0.0001` to
114/// `1.0` is the same as `0%` to `100%`.
115/// - `padding`: [f32] (default: `0.0`): Padding for every side of the SVG view box.
116/// ---
117/// - `donut`: [bool] (default: `false`): Draw the slices differently to make a donut-looking chart
118/// instead.
119/// - `donut_width`: [f32] (default: `40.0`): The width of each donut slice.
120/// ---
121/// - `class_chart`: &[str] (default: `"dx-pie-chart"`): The HTML element `class` of the
122/// pie chart.
123/// - `class_series`: &[str] (default: `"dx-series"`): The HTML element `class` for the group of
124/// pie slices.
125/// - `class_slice`: &[str] (default: `"dx-slice"`): The HTML element `class` for all pie
126/// slices.
127/// - `class_label`: &[str] (default: `"dx-label"`): The HTML element `class` for all labels.
128#[allow(non_snake_case)]
129pub fn PieChart(props: PieChartProps) -> Element {
130    if props.series.is_empty() {
131        return rsx!("Pie chart error: empty series");
132    }
133
134    let center = Point::new(
135        props.viewbox_width as f32 / 2.0,
136        props.viewbox_height as f32 / 2.0,
137    );
138    let center_min = center.x.min(center.y);
139    let radius = center_min - 30.0 - props.padding;
140    let label_radius = match props.label_position {
141        LabelPosition::Inside => radius / 2.0 + props.label_offset,
142        LabelPosition::Outside => radius + props.label_offset,
143        LabelPosition::Center => 0.0 + props.label_offset,
144    };
145
146    let normalized_series = normalize_series(&props.series);
147    let normalized_sum: f32 = normalized_series.iter().sum();
148
149    let values_total: f32 = if let Some(r) = props.show_ratio {
150        1.0 / r.clamp(0.0001, 1.0) * normalized_sum
151    } else if let Some(v) = props.total {
152        (normalized_sum / props.series.iter().sum::<f32>() * v).max(normalized_sum)
153    } else {
154        normalized_sum
155    };
156
157    let mut m_start_angle = props.start_angle;
158    let mut color_var = 255.0;
159    let mut class_index = 0;
160    let mut label_positions = Vec::<Point>::new();
161
162    let normalized_series_rsx = normalized_series.iter().filter_map(|v| {
163        if *v != 0.0 {
164            let mut end_angle = if values_total > 0.0 {
165                m_start_angle + (v / values_total) * 360.0
166            } else {
167                0.0
168            };
169            let overlap_start_angle = if class_index != 0 {
170                (m_start_angle - 0.4).max(0.0)
171            } else {
172                m_start_angle
173            };
174            if end_angle - overlap_start_angle >= 359.99 {
175                end_angle = overlap_start_angle + 359.99
176            }
177
178            let start_position = polar_to_cartesian(center, radius, overlap_start_angle);
179            let end_position = polar_to_cartesian(center, radius, end_angle);
180            let large_arc = i32::from(end_angle - m_start_angle > 180.0);
181
182            let dpath = if props.donut {
183                let donut_radius = radius - props.donut_width;
184                let start_inside_position = polar_to_cartesian(center, donut_radius, overlap_start_angle);
185                let end_inside_position = polar_to_cartesian(center, donut_radius, end_angle);
186                let large_arc_inside = large_arc;
187
188                format!("M{end_position}\
189                         A{radius},{radius},0,{large_arc},0,{start_position}\
190                         L{start_inside_position}\
191                         A{donut_radius},{donut_radius},0,{large_arc_inside},1,{end_inside_position}Z")
192            } else {
193                format!("M{end_position}\
194                         A{radius},{radius},0,{large_arc},0,{start_position}\
195                         L{center}Z")
196            };
197
198            let element = rsx! {
199                g {
200                    class: "{props.class_series} {props.class_series}-{class_index}",
201                    path {
202                        d: "{dpath}",
203                        class: "{props.class_slice}",
204                        fill: "rgb({color_var}, 40, 40)",
205                    },
206                }
207            };
208
209            label_positions.push(polar_to_cartesian(center, label_radius, m_start_angle + (end_angle - m_start_angle) / 2.0));
210
211            color_var -= 75.0 * (1.0 / (class_index + 1) as f32);
212            class_index += 1;
213            m_start_angle = end_angle;
214            Some(element)
215        } else {
216            label_positions.push(Point::new(-1.0, -1.0));
217            None
218        }
219    });
220
221    rsx! {
222        div {
223            svg {
224                view_box: "0 0 {props.viewbox_width} {props.viewbox_height}",
225                width: "{props.width}",
226                height: "{props.height}",
227                class: "{props.class_chart}",
228                preserve_aspect_ratio: "xMidYMid meet",
229                xmlns: "http://www.w3.org/2000/svg",
230
231                {normalized_series_rsx}
232
233                if let Some(ref labels) = props.labels {
234                    g {
235                        {
236                            label_positions.iter().zip(labels.iter()).filter_map(|(position, label)| {
237                                if position.x > 0.0 {
238                                    Some(rsx! {
239                                        text {
240                                            dx: "{position.x}",
241                                            dy: "{position.y}",
242                                            text_anchor: "middle",
243                                            class: "{props.class_label}",
244                                            alignment_baseline: "middle",
245                                            "{label}"
246                                        }
247                                    })
248                                } else {
249                                    None
250                                }
251                            })
252                        }
253                    }
254                } else if props.show_labels {
255                    g {
256                        {
257                            label_positions.iter().zip(props.series.iter()).filter_map(|(position, value)| {
258                                let label = if let Some(func) = props.label_interpolation {
259                                    func(*value)
260                                } else {
261                                    value.to_string()
262                                };
263
264                                if position.x > 0.0 {
265                                    Some(rsx! {
266                                        text {
267                                            dx: "{position.x}",
268                                            dy: "{position.y}",
269                                            text_anchor: "middle",
270                                            class: "{props.class_label}",
271                                            alignment_baseline: "middle",
272                                            "{label}"
273                                        }
274                                    })
275                                } else {
276                                    None
277                                }
278                            })
279                        }
280                    }
281                }
282            }
283        }
284    }
285}