embedded_charts/chart/
pie.rs

1//! Pie chart implementation.
2
3use crate::chart::traits::{Chart, ChartBuilder, ChartConfig};
4use crate::data::{DataPoint, DataSeries};
5use crate::error::{ChartError, ChartResult};
6use crate::math::Math;
7use crate::math::NumericConversion;
8use crate::style::BorderStyle;
9use embedded_graphics::{
10    draw_target::DrawTarget,
11    prelude::*,
12    primitives::{Circle, PrimitiveStyle, Rectangle},
13};
14use heapless::Vec;
15
16/// Pie chart implementation
17#[derive(Debug, Clone)]
18pub struct PieChart<C: PixelColor> {
19    style: PieChartStyle<C>,
20    config: ChartConfig<C>,
21    center: Point,
22    radius: u32,
23}
24
25/// Style configuration for pie charts
26#[derive(Debug, Clone)]
27pub struct PieChartStyle<C: PixelColor> {
28    /// Colors for pie slices
29    pub colors: Vec<C, 16>,
30    /// Border style for slices
31    pub border: Option<BorderStyle<C>>,
32    /// Label style configuration
33    pub labels: LabelStyle,
34    /// Starting angle in degrees (0 = right, 90 = top)
35    pub start_angle: f32,
36    /// Inner radius for donut charts (None = full pie)
37    pub donut_inner_radius: Option<u32>,
38}
39
40/// Label style for pie chart slices
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct LabelStyle {
43    /// Whether to show labels
44    pub visible: bool,
45    /// Whether to show percentage values
46    pub show_percentage: bool,
47    /// Whether to show actual values
48    pub show_values: bool,
49    /// Distance from pie edge to label
50    pub offset: u32,
51}
52
53/// Represents a pie slice with its properties
54#[derive(Debug, Clone, Copy)]
55pub struct PieSlice {
56    /// Start angle in radians
57    pub start_angle: f32,
58    /// End angle in radians
59    pub end_angle: f32,
60    /// Value of this slice
61    pub value: f32,
62    /// Percentage of total
63    pub percentage: f32,
64}
65
66impl<C: PixelColor> PieChart<C>
67where
68    C: From<embedded_graphics::pixelcolor::Rgb565>,
69{
70    /// Create a new pie chart with default styling
71    pub fn new(center: Point, radius: u32) -> Self {
72        Self {
73            style: PieChartStyle::default(),
74            config: ChartConfig::default(),
75            center,
76            radius,
77        }
78    }
79
80    /// Create a builder for configuring the pie chart
81    pub fn builder() -> PieChartBuilder<C> {
82        PieChartBuilder::new()
83    }
84
85    /// Set the pie chart style
86    pub fn set_style(&mut self, style: PieChartStyle<C>) {
87        self.style = style;
88    }
89
90    /// Get the current pie chart style
91    pub fn style(&self) -> &PieChartStyle<C> {
92        &self.style
93    }
94
95    /// Set the chart configuration
96    pub fn set_config(&mut self, config: ChartConfig<C>) {
97        self.config = config;
98    }
99
100    /// Get the chart configuration
101    pub fn config(&self) -> &ChartConfig<C> {
102        &self.config
103    }
104
105    /// Set the center point
106    pub fn set_center(&mut self, center: Point) {
107        self.center = center;
108    }
109
110    /// Get the center point
111    pub fn center(&self) -> Point {
112        self.center
113    }
114
115    /// Set the radius
116    pub fn set_radius(&mut self, radius: u32) {
117        self.radius = radius;
118    }
119
120    /// Get the radius
121    pub fn radius(&self) -> u32 {
122        self.radius
123    }
124
125    /// Calculate pie slices from data
126    fn calculate_slices(
127        &self,
128        data: &crate::data::series::StaticDataSeries<crate::data::point::Point2D, 256>,
129    ) -> ChartResult<Vec<PieSlice, 16>> {
130        let mut slices = Vec::new();
131
132        // Calculate total value
133        let total: f32 = data
134            .iter()
135            .map(|point| point.y())
136            .filter(|&value: &f32| value >= 0.0) // Only positive values
137            .sum();
138
139        if total <= 0.0 {
140            return Err(ChartError::InsufficientData);
141        }
142
143        // Convert start angle to radians
144        let start_angle_rad = self.style.start_angle.to_radians();
145        let mut current_angle = start_angle_rad;
146
147        // Create slices
148        for point in data.iter() {
149            let value: f32 = point.y();
150            if value < 0.0 {
151                continue; // Skip negative values
152            }
153
154            let percentage = value / total;
155            let angle_span = percentage * 2.0 * core::f32::consts::PI;
156            let end_angle = current_angle + angle_span;
157
158            let slice = PieSlice {
159                start_angle: current_angle,
160                end_angle,
161                value,
162                percentage: percentage * 100.0,
163            };
164
165            slices.push(slice).map_err(|_| ChartError::MemoryFull)?;
166            current_angle = end_angle;
167        }
168
169        Ok(slices)
170    }
171
172    /// Draw a pie slice using a custom implementation to avoid pixel overlap
173    fn draw_slice<D>(&self, slice: &PieSlice, color_index: usize, target: &mut D) -> ChartResult<()>
174    where
175        D: DrawTarget<Color = C>,
176    {
177        // Get slice color
178        let slice_color = if !self.style.colors.is_empty() {
179            self.style.colors[color_index % self.style.colors.len()]
180        } else {
181            return Err(ChartError::InvalidConfiguration);
182        };
183
184        // Custom pie slice drawing to avoid embedded-graphics Sector overlap issues
185        self.draw_pie_slice_custom(slice, slice_color, target)?;
186
187        Ok(())
188    }
189
190    /// Custom pie slice drawing implementation that avoids pixel overlap
191    fn draw_pie_slice_custom<D>(
192        &self,
193        slice: &PieSlice,
194        color: C,
195        target: &mut D,
196    ) -> ChartResult<()>
197    where
198        D: DrawTarget<Color = C>,
199    {
200        use embedded_graphics::Drawable;
201        use embedded_graphics::Pixel;
202
203        let center_x = self.center.x;
204        let center_y = self.center.y;
205        let radius_num = (self.radius as i32).to_number();
206
207        // Fill the slice by checking each pixel in the bounding box
208        let min_x = (center_x - self.radius as i32).max(0);
209        let max_x = center_x + self.radius as i32;
210        let min_y = (center_y - self.radius as i32).max(0);
211        let max_y = center_y + self.radius as i32;
212
213        // Constants using Number type
214        let zero = 0i32.to_number();
215        let pi = core::f32::consts::PI.to_number();
216        let two_pi = pi + pi;
217
218        for y in min_y..=max_y {
219            for x in min_x..=max_x {
220                let dx_num = (x - center_x).to_number();
221                let dy_num = (y - center_y).to_number();
222                let distance_squared = dx_num * dx_num + dy_num * dy_num;
223                let distance = Math::sqrt(distance_squared);
224
225                // Skip pixels outside the circle or at the exact center (to avoid overlap)
226                // Add small tolerance for better boundary handling
227                let tolerance = 0.5f32.to_number();
228                if distance > radius_num + tolerance || distance < tolerance {
229                    continue;
230                }
231
232                // Calculate angle from center to this pixel using proper atan2
233                // Note: Screen coordinates have y-axis flipped, so we negate dy for proper mathematical angles
234                let angle = Math::atan2(-dy_num, dx_num);
235
236                // Normalize angle to [0, 2π] using modulo operations
237                let normalized_angle = {
238                    let mut a = angle;
239                    if a < zero {
240                        a += two_pi;
241                    }
242                    // Use a simple normalization since we don't have modulo for Number type
243                    while a >= two_pi {
244                        a -= two_pi;
245                    }
246                    while a < zero {
247                        a += two_pi;
248                    }
249                    a
250                };
251
252                // Check if this pixel is within the slice
253                let start_angle_num = slice.start_angle.to_number();
254                let end_angle_num = slice.end_angle.to_number();
255
256                // Normalize slice angles to [0, 2π] using modulo operations
257                let start_norm = {
258                    let mut a = start_angle_num;
259                    while a >= two_pi {
260                        a -= two_pi;
261                    }
262                    while a < zero {
263                        a += two_pi;
264                    }
265                    a
266                };
267                let end_norm = {
268                    let mut a = end_angle_num;
269                    while a >= two_pi {
270                        a -= two_pi;
271                    }
272                    while a < zero {
273                        a += two_pi;
274                    }
275                    a
276                };
277
278                let in_slice = if start_norm <= end_norm {
279                    normalized_angle >= start_norm && normalized_angle <= end_norm
280                } else {
281                    // Handle wrap-around case
282                    normalized_angle >= start_norm || normalized_angle <= end_norm
283                };
284
285                if in_slice {
286                    let point = Point::new(x, y);
287                    Pixel(point, color)
288                        .draw(target)
289                        .map_err(|_| ChartError::RenderingError)?;
290                }
291            }
292        }
293
294        Ok(())
295    }
296
297    /// Draw the center circle for donut charts
298    fn draw_donut_center<D>(&self, target: &mut D) -> ChartResult<()>
299    where
300        D: DrawTarget<Color = C>,
301    {
302        if let Some(inner_radius) = self.style.donut_inner_radius {
303            // Use background color if available, otherwise use white as default
304            let center_color = self
305                .config
306                .background_color
307                .unwrap_or_else(|| embedded_graphics::pixelcolor::Rgb565::WHITE.into());
308
309            let fill_style = PrimitiveStyle::with_fill(center_color);
310            Circle::new(
311                Point::new(
312                    self.center.x - inner_radius as i32,
313                    self.center.y - inner_radius as i32,
314                ),
315                inner_radius * 2,
316            )
317            .into_styled(fill_style)
318            .draw(target)
319            .map_err(|_| ChartError::RenderingError)?;
320        }
321
322        Ok(())
323    }
324}
325impl<C: PixelColor> Default for PieChart<C>
326where
327    C: From<embedded_graphics::pixelcolor::Rgb565>,
328{
329    fn default() -> Self {
330        Self::new(Point::new(50, 50), 40)
331    }
332}
333
334impl<C: PixelColor> Chart<C> for PieChart<C>
335where
336    C: From<embedded_graphics::pixelcolor::Rgb565>,
337{
338    type Data = crate::data::series::StaticDataSeries<crate::data::point::Point2D, 256>;
339    type Config = ChartConfig<C>;
340
341    fn draw<D>(
342        &self,
343        data: &Self::Data,
344        config: &Self::Config,
345        viewport: Rectangle,
346        target: &mut D,
347    ) -> ChartResult<()>
348    where
349        D: DrawTarget<Color = C>,
350        Self::Data: DataSeries,
351        <Self::Data as DataSeries>::Item: DataPoint,
352        <<Self::Data as DataSeries>::Item as DataPoint>::Y: Into<f32> + Copy + PartialOrd,
353    {
354        if data.is_empty() {
355            return Err(ChartError::InsufficientData);
356        }
357
358        // Draw background if specified
359        if let Some(bg_color) = config.background_color {
360            Rectangle::new(viewport.top_left, viewport.size)
361                .into_styled(PrimitiveStyle::with_fill(bg_color))
362                .draw(target)
363                .map_err(|_| ChartError::RenderingError)?;
364        }
365
366        // Calculate the actual center position within the viewport
367        let title_height = if config.title.is_some() { 30 } else { 0 };
368        let available_height = viewport.size.height.saturating_sub(title_height);
369
370        // Center the pie chart in the available space
371        let center_x = viewport.top_left.x + (viewport.size.width as i32) / 2;
372        let center_y = viewport.top_left.y + title_height as i32 + (available_height as i32) / 2;
373        let actual_center = Point::new(center_x, center_y);
374
375        // Create a temporary pie chart with the calculated center for drawing
376        let mut chart_for_drawing = self.clone();
377        chart_for_drawing.center = actual_center;
378
379        // Calculate slices
380        let slices = chart_for_drawing.calculate_slices(data)?;
381
382        // Draw each slice using the chart with correct center
383        for (index, slice) in slices.iter().enumerate() {
384            chart_for_drawing.draw_slice(slice, index, target)?;
385        }
386
387        // Draw donut center if applicable
388        chart_for_drawing.draw_donut_center(target)?;
389
390        // Draw title if present
391        if let Some(title) = &config.title {
392            use embedded_graphics::{
393                mono_font::{ascii::FONT_6X10, MonoTextStyle},
394                text::{Alignment, Text},
395            };
396
397            let text_color = embedded_graphics::pixelcolor::Rgb565::BLACK.into();
398            let text_style = MonoTextStyle::new(&FONT_6X10, text_color);
399
400            let title_x = viewport.top_left.x + (viewport.size.width as i32) / 2;
401            let title_y = viewport.top_left.y + 15;
402
403            Text::with_alignment(
404                title,
405                Point::new(title_x, title_y),
406                text_style,
407                Alignment::Center,
408            )
409            .draw(target)
410            .map_err(|_| ChartError::RenderingError)?;
411        }
412
413        Ok(())
414    }
415}
416
417impl<C: PixelColor> Default for PieChartStyle<C>
418where
419    C: From<embedded_graphics::pixelcolor::Rgb565>,
420{
421    fn default() -> Self {
422        let mut colors = Vec::new();
423        let _ = colors.push(embedded_graphics::pixelcolor::Rgb565::BLUE.into());
424        let _ = colors.push(embedded_graphics::pixelcolor::Rgb565::RED.into());
425        let _ = colors.push(embedded_graphics::pixelcolor::Rgb565::GREEN.into());
426        let _ = colors.push(embedded_graphics::pixelcolor::Rgb565::YELLOW.into());
427        let _ = colors.push(embedded_graphics::pixelcolor::Rgb565::MAGENTA.into());
428        let _ = colors.push(embedded_graphics::pixelcolor::Rgb565::CYAN.into());
429
430        Self {
431            colors,
432            border: None,
433            labels: LabelStyle::default(),
434            start_angle: 0.0,
435            donut_inner_radius: None,
436        }
437    }
438}
439
440impl Default for LabelStyle {
441    fn default() -> Self {
442        Self {
443            visible: false,
444            show_percentage: true,
445            show_values: false,
446            offset: 10,
447        }
448    }
449}
450
451/// Builder for pie charts
452#[derive(Debug)]
453pub struct PieChartBuilder<C: PixelColor> {
454    style: PieChartStyle<C>,
455    config: ChartConfig<C>,
456    center: Point,
457    radius: u32,
458}
459
460impl<C: PixelColor> PieChartBuilder<C>
461where
462    C: From<embedded_graphics::pixelcolor::Rgb565>,
463{
464    /// Create a new pie chart builder
465    pub fn new() -> Self {
466        Self {
467            style: PieChartStyle::default(),
468            config: ChartConfig::default(),
469            center: Point::new(50, 50),
470            radius: 40,
471        }
472    }
473
474    /// Set the center point
475    pub fn center(mut self, center: Point) -> Self {
476        self.center = center;
477        self
478    }
479
480    /// Set the radius
481    pub fn radius(mut self, radius: u32) -> Self {
482        self.radius = radius;
483        self
484    }
485
486    /// Set slice colors
487    pub fn colors(mut self, colors: &[C]) -> Self {
488        self.style.colors.clear();
489        for &color in colors {
490            if self.style.colors.push(color).is_err() {
491                break; // Reached capacity
492            }
493        }
494        self
495    }
496
497    /// Set the starting angle
498    pub fn start_angle(mut self, angle: f32) -> Self {
499        self.style.start_angle = angle;
500        self
501    }
502
503    /// Make this a donut chart with the specified inner radius
504    pub fn donut(mut self, inner_radius: u32) -> Self {
505        self.style.donut_inner_radius = Some(inner_radius);
506        self
507    }
508
509    /// Make this a donut chart with inner radius as percentage of outer radius
510    ///
511    /// # Arguments
512    /// * `percentage` - Inner radius as percentage (0-100) of the outer radius
513    ///
514    /// # Examples
515    /// ```rust
516    /// # use embedded_charts::prelude::*;
517    /// # use embedded_graphics::pixelcolor::Rgb565;
518    /// # fn test() -> Result<(), embedded_charts::error::ChartError> {
519    /// // Create a balanced donut chart (40% inner radius)
520    /// let chart = PieChart::builder()
521    ///     .radius(100)
522    ///     .donut_percentage(40) // Inner radius will be 40 pixels
523    ///     .colors(&[Rgb565::BLUE, Rgb565::RED])
524    ///     .build()?;
525    /// # Ok(())
526    /// # }
527    /// ```
528    pub fn donut_percentage(mut self, percentage: u32) -> Self {
529        let percentage = percentage.min(100); // Cap at 100%
530        let inner_radius = (self.radius as f32 * percentage as f32 / 100.0) as u32;
531        self.style.donut_inner_radius = Some(inner_radius);
532        self
533    }
534
535    /// Make this a balanced donut chart with 50% inner radius
536    ///
537    /// This is a convenience method for creating visually balanced donut charts
538    /// that work well across different display sizes.
539    ///
540    /// # Examples
541    /// ```rust
542    /// # use embedded_charts::prelude::*;
543    /// # use embedded_graphics::pixelcolor::Rgb565;
544    /// # fn test() -> Result<(), embedded_charts::error::ChartError> {
545    /// let chart = PieChart::builder()
546    ///     .radius(80)
547    ///     .balanced_donut() // Inner radius will be 40 pixels (50%)
548    ///     .colors(&[Rgb565::BLUE, Rgb565::RED])
549    ///     .build()?;
550    /// # Ok(())
551    /// # }
552    /// ```
553    pub fn balanced_donut(self) -> Self {
554        self.donut_percentage(50)
555    }
556
557    /// Make this a thin donut chart with 25% inner radius
558    ///
559    /// Thin donuts emphasize the data segments while still providing
560    /// some center space. Good for detailed data analysis.
561    pub fn thin_donut(self) -> Self {
562        self.donut_percentage(25)
563    }
564
565    /// Make this a thick donut chart with 75% inner radius
566    ///
567    /// Thick donuts maximize center space for displaying additional
568    /// information like totals or status indicators.
569    pub fn thick_donut(self) -> Self {
570        self.donut_percentage(75)
571    }
572
573    /// Add a border to slices
574    pub fn with_border(mut self, border: BorderStyle<C>) -> Self {
575        self.style.border = Some(border);
576        self
577    }
578
579    /// Configure labels
580    pub fn labels(mut self, labels: LabelStyle) -> Self {
581        self.style.labels = labels;
582        self
583    }
584
585    /// Set the chart title
586    pub fn with_title(mut self, title: &str) -> Self {
587        if let Ok(title_string) = heapless::String::try_from(title) {
588            self.config.title = Some(title_string);
589        }
590        self
591    }
592
593    /// Set the background color
594    pub fn background_color(mut self, color: C) -> Self {
595        self.config.background_color = Some(color);
596        self
597    }
598}
599
600impl<C: PixelColor> ChartBuilder<C> for PieChartBuilder<C>
601where
602    C: From<embedded_graphics::pixelcolor::Rgb565>,
603{
604    type Chart = PieChart<C>;
605    type Error = ChartError;
606
607    fn build(self) -> Result<Self::Chart, Self::Error> {
608        Ok(PieChart {
609            style: self.style,
610            config: self.config,
611            center: self.center,
612            radius: self.radius,
613        })
614    }
615}
616
617impl<C: PixelColor> Default for PieChartBuilder<C>
618where
619    C: From<embedded_graphics::pixelcolor::Rgb565>,
620{
621    fn default() -> Self {
622        Self::new()
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use embedded_graphics::pixelcolor::Rgb565;
630
631    #[test]
632    fn test_pie_chart_creation() {
633        let chart: PieChart<Rgb565> = PieChart::new(Point::new(100, 100), 50);
634        assert_eq!(chart.center(), Point::new(100, 100));
635        assert_eq!(chart.radius(), 50);
636        assert!(chart.style().donut_inner_radius.is_none());
637    }
638
639    #[test]
640    fn test_pie_chart_builder() {
641        let chart: PieChart<Rgb565> = PieChart::builder()
642            .center(Point::new(150, 150))
643            .radius(60)
644            .colors(&[Rgb565::RED, Rgb565::BLUE, Rgb565::GREEN])
645            .start_angle(90.0)
646            .donut(20)
647            .with_title("Test Pie Chart")
648            .build()
649            .unwrap();
650
651        assert_eq!(chart.center(), Point::new(150, 150));
652        assert_eq!(chart.radius(), 60);
653        assert_eq!(chart.style().colors.len(), 3);
654        assert_eq!(chart.style().start_angle, 90.0);
655        assert_eq!(chart.style().donut_inner_radius, Some(20));
656        assert_eq!(
657            chart.config().title.as_ref().map(|s| s.as_str()),
658            Some("Test Pie Chart")
659        );
660    }
661
662    #[test]
663    fn test_label_style() {
664        let labels = LabelStyle {
665            visible: true,
666            show_percentage: true,
667            show_values: false,
668            offset: 15,
669        };
670
671        assert!(labels.visible);
672        assert!(labels.show_percentage);
673        assert!(!labels.show_values);
674        assert_eq!(labels.offset, 15);
675    }
676
677    #[test]
678    fn test_pie_slice() {
679        let slice = PieSlice {
680            start_angle: 0.0,
681            end_angle: core::f32::consts::PI / 2.0,
682            value: 25.0,
683            percentage: 25.0,
684        };
685
686        assert_eq!(slice.value, 25.0);
687        assert_eq!(slice.percentage, 25.0);
688        assert_eq!(slice.start_angle, 0.0);
689    }
690
691    #[test]
692    fn test_donut_percentage() {
693        // Test 50% donut
694        let chart: PieChart<Rgb565> = PieChart::builder()
695            .radius(100)
696            .donut_percentage(50)
697            .build()
698            .unwrap();
699
700        assert_eq!(chart.style().donut_inner_radius, Some(50));
701
702        // Test 25% donut
703        let chart: PieChart<Rgb565> = PieChart::builder()
704            .radius(80)
705            .donut_percentage(25)
706            .build()
707            .unwrap();
708
709        assert_eq!(chart.style().donut_inner_radius, Some(20));
710
711        // Test percentage cap at 100%
712        let chart: PieChart<Rgb565> = PieChart::builder()
713            .radius(60)
714            .donut_percentage(150) // Should be capped at 100%
715            .build()
716            .unwrap();
717
718        assert_eq!(chart.style().donut_inner_radius, Some(60));
719    }
720
721    #[test]
722    fn test_donut_convenience_methods() {
723        // Test balanced donut (50%)
724        let chart: PieChart<Rgb565> = PieChart::builder()
725            .radius(100)
726            .balanced_donut()
727            .build()
728            .unwrap();
729
730        assert_eq!(chart.style().donut_inner_radius, Some(50));
731
732        // Test thin donut (25%)
733        let chart: PieChart<Rgb565> = PieChart::builder().radius(80).thin_donut().build().unwrap();
734
735        assert_eq!(chart.style().donut_inner_radius, Some(20));
736
737        // Test thick donut (75%)
738        let chart: PieChart<Rgb565> = PieChart::builder()
739            .radius(60)
740            .thick_donut()
741            .build()
742            .unwrap();
743
744        assert_eq!(chart.style().donut_inner_radius, Some(45));
745    }
746
747    #[test]
748    fn test_donut_vs_regular_pie() {
749        // Regular pie chart (no donut)
750        let pie: PieChart<Rgb565> = PieChart::builder().radius(50).build().unwrap();
751
752        assert_eq!(pie.style().donut_inner_radius, None);
753
754        // Donut chart
755        let donut: PieChart<Rgb565> = PieChart::builder().radius(50).donut(20).build().unwrap();
756
757        assert_eq!(donut.style().donut_inner_radius, Some(20));
758    }
759}