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}