1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum PieStyle {
15 #[default]
17 Pie,
18 Donut,
20}
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum PieLabelStyle {
25 #[default]
27 None,
28 Value,
30 Percent,
32 Label,
34 LabelPercent,
36}
37
38#[derive(Clone, Debug)]
40pub struct PieSlice {
41 pub label: String,
43 pub value: f64,
45 pub color: Option<Color>,
47}
48
49impl PieSlice {
50 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 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
69pub struct PieChart {
71 slices: Vec<PieSlice>,
73 style: PieStyle,
75 legend: Legend,
77 colors: ColorScheme,
79 start_angle: f64,
81 explode: Option<usize>,
83 explode_distance: f64,
85 labels: PieLabelStyle,
87 donut_ratio: f64,
89 title: Option<String>,
91 bg_color: Option<Color>,
93 props: WidgetProps,
95}
96
97impl Default for PieChart {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl PieChart {
104 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, 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 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 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 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 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 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 pub fn legend(mut self, legend: Legend) -> Self {
164 self.legend = legend;
165 self
166 }
167
168 pub fn no_legend(mut self) -> Self {
170 self.legend = Legend::none();
171 self
172 }
173
174 pub fn colors(mut self, colors: ColorScheme) -> Self {
176 self.colors = colors;
177 self
178 }
179
180 pub fn start_angle(mut self, angle: f64) -> Self {
182 self.start_angle = angle;
183 self
184 }
185
186 pub fn explode(mut self, index: usize) -> Self {
188 self.explode = Some(index);
189 self
190 }
191
192 pub fn explode_distance(mut self, distance: f64) -> Self {
194 self.explode_distance = distance.clamp(0.0, 0.5);
195 self
196 }
197
198 pub fn labels(mut self, style: PieLabelStyle) -> Self {
200 self.labels = style;
201 self
202 }
203
204 pub fn title(mut self, title: impl Into<String>) -> Self {
206 self.title = Some(title.into());
207 self
208 }
209
210 pub fn bg(mut self, color: Color) -> Self {
212 self.bg_color = Some(color);
213 self
214 }
215
216 fn total(&self) -> f64 {
218 self.slices.iter().map(|s| s.value).sum()
219 }
220
221 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 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 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 let aspect_ratio = 2.0;
248
249 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 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 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 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 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 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 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 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 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 if let Some(bg) = self.bg_color {
389 fill_background(ctx, area, bg);
390 }
391
392 let title_offset = render_title(ctx, area, self.title.as_deref(), Color::WHITE);
394
395 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 self.render_pie(ctx, center_x, center_y, radius);
405
406 self.render_labels(ctx, center_x, center_y, radius);
408
409 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
426pub fn pie_chart() -> PieChart {
428 PieChart::new()
429}
430
431pub 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 let color0 = chart.slice_color(0);
478 assert_ne!(color0.r, 0);
479
480 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 #[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 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 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 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 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 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 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 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 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}