revue/widget/
piechart.rs

1//! Pie Chart widget for proportional data visualization
2//!
3//! Supports standard pie charts, donut charts, labels, legends, and exploded segments.
4
5use super::chart_common::{ColorScheme, Legend, LegendPosition};
6use super::chart_render::{fill_background, render_legend, render_title, LegendItem};
7use super::traits::{RenderContext, View, WidgetProps};
8use crate::render::Cell;
9use crate::style::Color;
10use crate::{impl_props_builders, impl_styled_view};
11
12/// Pie chart style
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum PieStyle {
15    /// Standard pie chart
16    #[default]
17    Pie,
18    /// Donut chart with hollow center
19    Donut,
20}
21
22/// Label display style for pie slices
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum PieLabelStyle {
25    /// No labels
26    #[default]
27    None,
28    /// Show value
29    Value,
30    /// Show percentage
31    Percent,
32    /// Show slice label
33    Label,
34    /// Show label and percentage
35    LabelPercent,
36}
37
38/// A single slice in the pie chart
39#[derive(Clone, Debug)]
40pub struct PieSlice {
41    /// Slice label
42    pub label: String,
43    /// Slice value
44    pub value: f64,
45    /// Custom color (uses palette if None)
46    pub color: Option<Color>,
47}
48
49impl PieSlice {
50    /// Create a new slice
51    pub fn new(label: impl Into<String>, value: f64) -> Self {
52        Self {
53            label: label.into(),
54            value,
55            color: None,
56        }
57    }
58
59    /// Create a slice with custom color
60    pub fn with_color(label: impl Into<String>, value: f64, color: Color) -> Self {
61        Self {
62            label: label.into(),
63            value,
64            color: Some(color),
65        }
66    }
67}
68
69/// Pie chart widget
70pub struct PieChart {
71    /// Pie slices
72    slices: Vec<PieSlice>,
73    /// Chart style (pie or donut)
74    style: PieStyle,
75    /// Legend configuration
76    legend: Legend,
77    /// Color palette
78    colors: ColorScheme,
79    /// Start angle in degrees (-90 = top)
80    start_angle: f64,
81    /// Index of exploded slice
82    explode: Option<usize>,
83    /// Explode distance (0.0-1.0)
84    explode_distance: f64,
85    /// Label style
86    labels: PieLabelStyle,
87    /// Donut hole ratio (0.0-1.0)
88    donut_ratio: f64,
89    /// Chart title
90    title: Option<String>,
91    /// Background color
92    bg_color: Option<Color>,
93    /// Widget properties
94    props: WidgetProps,
95}
96
97impl Default for PieChart {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl PieChart {
104    /// Create a new pie chart
105    pub fn new() -> Self {
106        Self {
107            slices: Vec::new(),
108            style: PieStyle::Pie,
109            legend: Legend::new().position(LegendPosition::TopRight),
110            colors: ColorScheme::default_palette(),
111            start_angle: -90.0, // Start from top
112            explode: None,
113            explode_distance: 0.15,
114            labels: PieLabelStyle::None,
115            donut_ratio: 0.0,
116            title: None,
117            bg_color: None,
118            props: WidgetProps::new(),
119        }
120    }
121
122    /// Add a slice
123    pub fn slice(mut self, label: impl Into<String>, value: f64) -> Self {
124        self.slices.push(PieSlice::new(label, value));
125        self
126    }
127
128    /// Add a colored slice
129    pub fn slice_colored(mut self, label: impl Into<String>, value: f64, color: Color) -> Self {
130        self.slices.push(PieSlice::with_color(label, value, color));
131        self
132    }
133
134    /// Add multiple slices from iterator
135    pub fn slices<I, S>(mut self, slices: I) -> Self
136    where
137        I: IntoIterator<Item = (S, f64)>,
138        S: Into<String>,
139    {
140        for (label, value) in slices {
141            self.slices.push(PieSlice::new(label, value));
142        }
143        self
144    }
145
146    /// Set chart style (pie or donut)
147    pub fn style(mut self, style: PieStyle) -> Self {
148        self.style = style;
149        if style == PieStyle::Donut && self.donut_ratio == 0.0 {
150            self.donut_ratio = 0.5;
151        }
152        self
153    }
154
155    /// Make it a donut chart with specified hole ratio
156    pub fn donut(mut self, ratio: f64) -> Self {
157        self.style = PieStyle::Donut;
158        self.donut_ratio = ratio.clamp(0.0, 0.9);
159        self
160    }
161
162    /// Set legend configuration
163    pub fn legend(mut self, legend: Legend) -> Self {
164        self.legend = legend;
165        self
166    }
167
168    /// Hide the legend
169    pub fn no_legend(mut self) -> Self {
170        self.legend = Legend::none();
171        self
172    }
173
174    /// Set color scheme
175    pub fn colors(mut self, colors: ColorScheme) -> Self {
176        self.colors = colors;
177        self
178    }
179
180    /// Set start angle in degrees (-90 = top, 0 = right)
181    pub fn start_angle(mut self, angle: f64) -> Self {
182        self.start_angle = angle;
183        self
184    }
185
186    /// Explode a slice (pull it out)
187    pub fn explode(mut self, index: usize) -> Self {
188        self.explode = Some(index);
189        self
190    }
191
192    /// Set explode distance
193    pub fn explode_distance(mut self, distance: f64) -> Self {
194        self.explode_distance = distance.clamp(0.0, 0.5);
195        self
196    }
197
198    /// Set label style
199    pub fn labels(mut self, style: PieLabelStyle) -> Self {
200        self.labels = style;
201        self
202    }
203
204    /// Set chart title
205    pub fn title(mut self, title: impl Into<String>) -> Self {
206        self.title = Some(title.into());
207        self
208    }
209
210    /// Set background color
211    pub fn bg(mut self, color: Color) -> Self {
212        self.bg_color = Some(color);
213        self
214    }
215
216    /// Get total of all slice values
217    fn total(&self) -> f64 {
218        self.slices.iter().map(|s| s.value).sum()
219    }
220
221    /// Get color for slice at index
222    fn slice_color(&self, index: usize) -> Color {
223        self.slices
224            .get(index)
225            .and_then(|s| s.color)
226            .unwrap_or_else(|| self.colors.get(index))
227    }
228
229    /// Calculate angle for a slice
230    fn slice_angle(&self, value: f64) -> f64 {
231        let total = self.total();
232        if total == 0.0 {
233            0.0
234        } else {
235            (value / total) * 360.0
236        }
237    }
238
239    /// Render the pie chart using simple ASCII/Unicode
240    fn render_pie(&self, ctx: &mut RenderContext, center_x: u16, center_y: u16, radius: u16) {
241        let total = self.total();
242        if total == 0.0 || self.slices.is_empty() {
243            return;
244        }
245
246        // Aspect ratio correction for terminal characters (typically 2:1)
247        let aspect_ratio = 2.0;
248
249        // Draw the pie using polar coordinates
250        let mut current_angle = self.start_angle;
251
252        for (slice_idx, slice) in self.slices.iter().enumerate() {
253            let slice_angle = self.slice_angle(slice.value);
254            let color = self.slice_color(slice_idx);
255
256            // Calculate explode offset if this slice is exploded
257            let (offset_x, offset_y) = if self.explode == Some(slice_idx) {
258                let mid_angle = current_angle + slice_angle / 2.0;
259                let rad = mid_angle.to_radians();
260                let offset = self.explode_distance * radius as f64;
261                (
262                    (offset * rad.cos() * aspect_ratio) as i16,
263                    (offset * rad.sin()) as i16,
264                )
265            } else {
266                (0, 0)
267            };
268
269            // Draw filled slice
270            for y in 0..=(radius * 2) {
271                for x in 0..=(radius * 2) {
272                    let dx = x as f64 - radius as f64;
273                    let dy = (y as f64 - radius as f64) * aspect_ratio;
274
275                    // Check if point is within the slice
276                    let distance = (dx * dx + dy * dy).sqrt();
277                    let inner_radius = if self.style == PieStyle::Donut {
278                        radius as f64 * self.donut_ratio
279                    } else {
280                        0.0
281                    };
282
283                    if distance > radius as f64 || distance < inner_radius {
284                        continue;
285                    }
286
287                    // Calculate angle of this point
288                    let point_angle = dy.atan2(dx).to_degrees();
289                    let point_angle = ((point_angle - self.start_angle) % 360.0 + 360.0) % 360.0;
290
291                    // Check if within slice
292                    let slice_start = ((current_angle - self.start_angle) % 360.0 + 360.0) % 360.0;
293                    let slice_end = slice_start + slice_angle;
294
295                    let in_slice = if slice_end <= 360.0 {
296                        point_angle >= slice_start && point_angle < slice_end
297                    } else {
298                        point_angle >= slice_start || point_angle < (slice_end - 360.0)
299                    };
300
301                    if in_slice {
302                        let screen_x =
303                            (center_x as i16 + offset_x + x as i16 - radius as i16) as u16;
304                        let screen_y =
305                            (center_y as i16 + offset_y + (y as i16 - radius as i16) / 2) as u16;
306
307                        if screen_x < ctx.buffer.width() && screen_y < ctx.buffer.height() {
308                            let mut cell = Cell::new('█');
309                            cell.fg = Some(color);
310                            ctx.buffer.set(screen_x, screen_y, cell);
311                        }
312                    }
313                }
314            }
315
316            current_angle += slice_angle;
317        }
318    }
319
320    /// Render labels around the pie
321    fn render_labels(&self, ctx: &mut RenderContext, center_x: u16, center_y: u16, radius: u16) {
322        if matches!(self.labels, PieLabelStyle::None) {
323            return;
324        }
325
326        let total = self.total();
327        if total == 0.0 {
328            return;
329        }
330
331        let mut current_angle = self.start_angle;
332
333        for slice in &self.slices {
334            let slice_angle = self.slice_angle(slice.value);
335            let mid_angle = current_angle + slice_angle / 2.0;
336            let rad = mid_angle.to_radians();
337
338            // Position label outside the pie
339            let label_distance = radius as f64 * 1.3;
340            let label_x = center_x as f64 + label_distance * rad.cos() * 2.0;
341            let label_y = center_y as f64 + label_distance * rad.sin();
342
343            let label_text = match self.labels {
344                PieLabelStyle::None => String::new(),
345                PieLabelStyle::Value => format!("{:.1}", slice.value),
346                PieLabelStyle::Percent => {
347                    format!("{:.0}%", (slice.value / total) * 100.0)
348                }
349                PieLabelStyle::Label => slice.label.clone(),
350                PieLabelStyle::LabelPercent => {
351                    format!("{} ({:.0}%)", slice.label, (slice.value / total) * 100.0)
352                }
353            };
354
355            // Draw label
356            let start_x = if mid_angle.cos() < 0.0 {
357                (label_x - label_text.len() as f64).max(0.0) as u16
358            } else {
359                label_x as u16
360            };
361
362            for (i, ch) in label_text.chars().enumerate() {
363                let x = start_x + i as u16;
364                let y = label_y as u16;
365                if x < ctx.buffer.width() && y < ctx.buffer.height() {
366                    let mut cell = Cell::new(ch);
367                    cell.fg = Some(Color::WHITE);
368                    ctx.buffer.set(x, y, cell);
369                }
370            }
371
372            current_angle += slice_angle;
373        }
374    }
375}
376
377impl View for PieChart {
378    crate::impl_view_meta!("PieChart");
379
380    fn render(&self, ctx: &mut RenderContext) {
381        let area = ctx.area;
382
383        if area.width < 3 || area.height < 3 {
384            return;
385        }
386
387        // Fill background if set
388        if let Some(bg) = self.bg_color {
389            fill_background(ctx, area, bg);
390        }
391
392        // Draw title using shared function
393        let title_offset = render_title(ctx, area, self.title.as_deref(), Color::WHITE);
394
395        // Calculate pie center and radius
396        let chart_area_height = area.height.saturating_sub(title_offset);
397        let radius = (chart_area_height.min(area.width / 2))
398            .saturating_sub(2)
399            .max(1);
400        let center_x = area.x + area.width / 2;
401        let center_y = area.y + title_offset + chart_area_height / 2;
402
403        // Render pie
404        self.render_pie(ctx, center_x, center_y, radius);
405
406        // Render labels
407        self.render_labels(ctx, center_x, center_y, radius);
408
409        // Render legend using shared function
410        let legend_items: Vec<LegendItem<'_>> = self
411            .slices
412            .iter()
413            .enumerate()
414            .map(|(i, s)| LegendItem {
415                label: &s.label,
416                color: self.slice_color(i),
417            })
418            .collect();
419        render_legend(ctx, area, &self.legend, &legend_items);
420    }
421}
422
423impl_styled_view!(PieChart);
424impl_props_builders!(PieChart);
425
426/// Create a new pie chart
427pub fn pie_chart() -> PieChart {
428    PieChart::new()
429}
430
431/// Create a donut chart
432pub fn donut_chart() -> PieChart {
433    PieChart::new().donut(0.5)
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn test_pie_chart_new() {
442        let chart = PieChart::new();
443        assert!(chart.slices.is_empty());
444        assert_eq!(chart.style, PieStyle::Pie);
445        assert_eq!(chart.start_angle, -90.0);
446    }
447
448    #[test]
449    fn test_pie_chart_slices() {
450        let chart = PieChart::new()
451            .slice("A", 30.0)
452            .slice("B", 50.0)
453            .slice("C", 20.0);
454
455        assert_eq!(chart.slices.len(), 3);
456        assert_eq!(chart.total(), 100.0);
457    }
458
459    #[test]
460    fn test_pie_chart_slice_angles() {
461        let chart = PieChart::new()
462            .slice("A", 25.0)
463            .slice("B", 25.0)
464            .slice("C", 25.0)
465            .slice("D", 25.0);
466
467        assert_eq!(chart.slice_angle(25.0), 90.0);
468    }
469
470    #[test]
471    fn test_pie_chart_colors() {
472        let chart = PieChart::new()
473            .slice("A", 30.0)
474            .slice_colored("B", 50.0, Color::RED);
475
476        // First slice uses palette
477        let color0 = chart.slice_color(0);
478        assert_ne!(color0.r, 0);
479
480        // Second slice uses custom color
481        let color1 = chart.slice_color(1);
482        assert_eq!(color1.r, 255);
483    }
484
485    #[test]
486    fn test_donut_chart() {
487        let chart = PieChart::new().donut(0.6);
488
489        assert_eq!(chart.style, PieStyle::Donut);
490        assert_eq!(chart.donut_ratio, 0.6);
491    }
492
493    #[test]
494    fn test_pie_chart_explode() {
495        let chart = PieChart::new().slice("A", 30.0).slice("B", 50.0).explode(0);
496
497        assert_eq!(chart.explode, Some(0));
498    }
499
500    #[test]
501    fn test_pie_chart_labels() {
502        let chart = PieChart::new().labels(PieLabelStyle::Percent);
503        assert_eq!(chart.labels, PieLabelStyle::Percent);
504    }
505
506    #[test]
507    fn test_pie_chart_legend() {
508        let chart = PieChart::new().legend(Legend::bottom_left());
509        assert_eq!(chart.legend.position, LegendPosition::BottomLeft);
510
511        let chart = PieChart::new().no_legend();
512        assert!(!chart.legend.is_visible());
513    }
514
515    #[test]
516    fn test_pie_chart_builder_chain() {
517        let chart = PieChart::new()
518            .title("Sales")
519            .slice("Product A", 100.0)
520            .slice("Product B", 200.0)
521            .slice("Product C", 150.0)
522            .donut(0.4)
523            .labels(PieLabelStyle::LabelPercent)
524            .legend(Legend::right())
525            .explode(1)
526            .start_angle(0.0);
527
528        assert_eq!(chart.title, Some("Sales".to_string()));
529        assert_eq!(chart.slices.len(), 3);
530        assert_eq!(chart.style, PieStyle::Donut);
531        assert_eq!(chart.donut_ratio, 0.4);
532        assert_eq!(chart.labels, PieLabelStyle::LabelPercent);
533        assert_eq!(chart.explode, Some(1));
534        assert_eq!(chart.start_angle, 0.0);
535    }
536
537    #[test]
538    fn test_pie_helpers() {
539        let chart = pie_chart();
540        assert_eq!(chart.style, PieStyle::Pie);
541
542        let chart = donut_chart();
543        assert_eq!(chart.style, PieStyle::Donut);
544        assert_eq!(chart.donut_ratio, 0.5);
545    }
546
547    #[test]
548    fn test_pie_slice_struct() {
549        let slice = PieSlice::new("Test", 42.0);
550        assert_eq!(slice.label, "Test");
551        assert_eq!(slice.value, 42.0);
552        assert!(slice.color.is_none());
553
554        let slice = PieSlice::with_color("Colored", 100.0, Color::BLUE);
555        assert!(slice.color.is_some());
556    }
557
558    // ========== Render Tests ==========
559
560    #[test]
561    fn test_pie_chart_render_basic() {
562        use crate::layout::Rect;
563        use crate::render::Buffer;
564        use crate::widget::traits::RenderContext;
565
566        let mut buffer = Buffer::new(30, 15);
567        let area = Rect::new(0, 0, 30, 15);
568        let mut ctx = RenderContext::new(&mut buffer, area);
569
570        let chart = PieChart::new().slice("A", 50.0).slice("B", 50.0);
571
572        chart.render(&mut ctx);
573
574        // Verify something was rendered (not all spaces)
575        let mut has_content = false;
576        for y in 0..15 {
577            for x in 0..30 {
578                if let Some(cell) = buffer.get(x, y) {
579                    if cell.symbol != ' ' {
580                        has_content = true;
581                        break;
582                    }
583                }
584            }
585        }
586        assert!(has_content);
587    }
588
589    #[test]
590    fn test_pie_chart_render_with_title() {
591        use crate::layout::Rect;
592        use crate::render::Buffer;
593        use crate::widget::traits::RenderContext;
594
595        let mut buffer = Buffer::new(30, 15);
596        let area = Rect::new(0, 0, 30, 15);
597        let mut ctx = RenderContext::new(&mut buffer, area);
598
599        let chart = PieChart::new().title("Test Chart").slice("A", 100.0);
600
601        chart.render(&mut ctx);
602
603        // Title should be rendered at the top
604        let mut title_found = false;
605        for x in 0..30 {
606            if let Some(cell) = buffer.get(x, 0) {
607                if cell.symbol == 'T' {
608                    title_found = true;
609                    break;
610                }
611            }
612        }
613        assert!(title_found);
614    }
615
616    #[test]
617    fn test_pie_chart_render_donut() {
618        use crate::layout::Rect;
619        use crate::render::Buffer;
620        use crate::widget::traits::RenderContext;
621
622        let mut buffer = Buffer::new(30, 15);
623        let area = Rect::new(0, 0, 30, 15);
624        let mut ctx = RenderContext::new(&mut buffer, area);
625
626        let chart = donut_chart().slice("A", 50.0).slice("B", 50.0);
627
628        chart.render(&mut ctx);
629
630        // Verify donut renders (has content)
631        let mut has_content = false;
632        for y in 0..15 {
633            for x in 0..30 {
634                if let Some(cell) = buffer.get(x, y) {
635                    if cell.symbol != ' ' {
636                        has_content = true;
637                        break;
638                    }
639                }
640            }
641        }
642        assert!(has_content);
643    }
644
645    #[test]
646    fn test_pie_chart_render_small_area() {
647        use crate::layout::Rect;
648        use crate::render::Buffer;
649        use crate::widget::traits::RenderContext;
650
651        // Very small area - should handle gracefully
652        let mut buffer = Buffer::new(5, 3);
653        let area = Rect::new(0, 0, 5, 3);
654        let mut ctx = RenderContext::new(&mut buffer, area);
655
656        let chart = PieChart::new().slice("A", 100.0);
657
658        // Should not panic
659        chart.render(&mut ctx);
660    }
661
662    #[test]
663    fn test_pie_chart_render_with_legend() {
664        use crate::layout::Rect;
665        use crate::render::Buffer;
666        use crate::widget::traits::RenderContext;
667
668        let mut buffer = Buffer::new(40, 20);
669        let area = Rect::new(0, 0, 40, 20);
670        let mut ctx = RenderContext::new(&mut buffer, area);
671
672        let chart = PieChart::new()
673            .slice("Alpha", 50.0)
674            .slice("Beta", 30.0)
675            .slice("Gamma", 20.0)
676            .legend(Legend::bottom_center());
677
678        chart.render(&mut ctx);
679
680        // Verify legend area has content (look for legend markers)
681        let mut legend_found = false;
682        for y in 15..20 {
683            for x in 0..40 {
684                if let Some(cell) = buffer.get(x, y) {
685                    if cell.symbol == '■' || cell.symbol == '●' {
686                        legend_found = true;
687                        break;
688                    }
689                }
690            }
691        }
692        assert!(legend_found);
693    }
694
695    #[test]
696    fn test_pie_chart_render_empty() {
697        use crate::layout::Rect;
698        use crate::render::Buffer;
699        use crate::widget::traits::RenderContext;
700
701        let mut buffer = Buffer::new(20, 10);
702        let area = Rect::new(0, 0, 20, 10);
703        let mut ctx = RenderContext::new(&mut buffer, area);
704
705        // Empty chart - should not panic
706        let chart = PieChart::new();
707        chart.render(&mut ctx);
708    }
709
710    #[test]
711    fn test_pie_chart_render_labels() {
712        use crate::layout::Rect;
713        use crate::render::Buffer;
714        use crate::widget::traits::RenderContext;
715
716        let mut buffer = Buffer::new(50, 25);
717        let area = Rect::new(0, 0, 50, 25);
718        let mut ctx = RenderContext::new(&mut buffer, area);
719
720        let chart = PieChart::new()
721            .slice("A", 50.0)
722            .slice("B", 50.0)
723            .labels(PieLabelStyle::Percent);
724
725        chart.render(&mut ctx);
726
727        // Check if percentage labels are rendered (look for %)
728        let mut percent_found = false;
729        for y in 0..25 {
730            for x in 0..50 {
731                if let Some(cell) = buffer.get(x, y) {
732                    if cell.symbol == '%' {
733                        percent_found = true;
734                        break;
735                    }
736                }
737            }
738        }
739        assert!(percent_found);
740    }
741}