Source code for charted.charts.pie

from __future__ import annotations

import math

from charted.charts.chart import Chart
from charted.html.element import G, Path, Text
from charted.utils.colors import complementary_color, get_contrast_color
from charted.utils.defaults import DEFAULT_COLORS
from charted.utils.themes import Theme
from charted.utils.types import Labels, Vector


[docs] class PieChart(Chart): """Pie chart for displaying categorical data as proportional slices.""" render_axes = False # Pie charts don't need axes or grid lines def __init__( self, data: Vector, labels: Labels = None, width: float = 500, height: float = 500, title: str | None = None, theme: Theme | None = None, inner_radius: float = 0, explode: float | Vector = 0, start_angle: float = 0, ): """Initialize pie chart. Args: data: Values for each slice (must be non-negative, sum > 0) labels: Optional labels for each slice width, height: Chart dimensions in pixels title: Optional chart title theme: Optional theme configuration inner_radius: Ratio (0.0-1.0) for doughnut hole; 0 = regular pie explode: Single value or list to offset slices from center (pixels) start_angle: Starting angle in degrees (0 = top, clockwise) """ # Validate inputs if not data or len(data) == 0: raise ValueError("Data cannot be empty") if any( not isinstance(v, (int, float)) or math.isnan(v) or math.isinf(v) for v in data ): raise ValueError("Data must contain only valid numbers") if any(v < 0 for v in data): raise ValueError("Data values cannot be negative") total = sum(data) if total == 0: raise ValueError("Total of all values must be greater than 0") self.inner_radius = inner_radius self.explode = explode if isinstance(explode, list) else [explode] * len(data) self.start_angle = start_angle self._pie_data = list(data) # Store original data for rendering self._pie_labels = labels # Create synthetic x_data and y_data for Chart base class compatibility x_data = [[i for i in range(len(data))]] y_data = [[0, 1]] # Minimal y range super().__init__( width=width, height=height, x_data=x_data, y_data=y_data, y_labels=labels, title=title, zero_index=True, theme=theme, ) # Override colors to match data length self.colors = data # Will trigger color generation @property def colors(self) -> list[str]: return self._colors @colors.setter def colors(self, data: Vector) -> None: """Generate colors based on data length.""" if not data: self._colors = list(DEFAULT_COLORS) return n = len(data) # Use DEFAULT_COLORS as base and generate complementary colors base_colors = list(DEFAULT_COLORS) self._colors = [] for i in range(n): color_idx = i % len(base_colors) if i < len(base_colors): self._colors.append(base_colors[color_idx]) else: # Generate additional complementary colors self._colors.append(complementary_color(base_colors[color_idx])) def _get_slice_path( self, cx: float, cy: float, radius: float, start_angle: float, end_angle: float ) -> str: """Generate SVG path data for a pie slice. Args: cx, cy: center coordinates radius: radius of the pie start_angle, end_angle: in degrees (0 = top, clockwise) """ # Convert angles to radians (subtract 90deg to shift: 0deg->top, positive=clockwise) start_rad = math.radians(start_angle - 90) end_rad = math.radians(end_angle - 90) # Calculate start and end points on circumference x1 = cx + radius * math.cos(start_rad) y1 = cy + radius * math.sin(start_rad) x2 = cx + radius * math.cos(end_rad) y2 = cy + radius * math.sin(end_rad) # Determine large_arc flag (1 if arc > 180deg, else 0) angle_span = (end_angle - start_angle) % 360 large_arc = 1 if angle_span > 180 else 0 # Build path: move to center, line to start, arc to end, close if self.inner_radius > 0: # Doughnut mode: need inner path too # inner_radius is a ratio (0.0-1.0) of the outer radius actual_inner_radius = radius * self.inner_radius inner_x1 = cx + actual_inner_radius * math.cos(start_rad) inner_y1 = cy + actual_inner_radius * math.sin(start_rad) inner_x2 = cx + actual_inner_radius * math.cos(end_rad) inner_y2 = cy + actual_inner_radius * math.sin(end_rad) path = [ f"M {x1} {y1}", # Start at outer edge f"A {radius} {radius} 0 {large_arc} 1 {x2} {y2}", # Arc to end f"L {inner_x2} {inner_y2}", # Line to inner edge f"A {actual_inner_radius} {actual_inner_radius} 0 {large_arc} 0 {inner_x1} {inner_y1}", # Inner arc "Z", # Close ] else: # Standard pie: move to center, line to start, arc to end, close path = [ f"M {cx} {cy}", # Move to center f"L {x1} {y1}", # Line to start point f"A {radius} {radius} 0 {large_arc} 1 {x2} {y2}", # Arc to end "Z", # Close (back to center) ] return " ".join(path) def _get_full_circle_path(self, cx: float, cy: float, radius: float) -> list[str]: """Generate SVG path for a full circle (100% slice case).""" if self.inner_radius > 0: # Doughnut: use two circles with different fill rules actual_inner_radius = radius * self.inner_radius return [ f"M {cx} {cy - radius}", f"A {radius} {radius} 0 1 1 {cx} {cy + radius}", f"A {radius} {radius} 0 1 1 {cx} {cy - radius}", "Z", f"M {cx} {cy - actual_inner_radius}", f"A {actual_inner_radius} {actual_inner_radius} 0 1 0 {cx} {cy + actual_inner_radius}", f"A {actual_inner_radius} {actual_inner_radius} 0 1 0 {cx} {cy - actual_inner_radius}", "Z", ] else: return [ f"M {cx} {cy - radius}", f"A {radius} {radius} 0 1 1 {cx} {cy + radius}", f"A {radius} {radius} 0 1 1 {cx} {cy - radius}", "Z", ] @property def representation(self) -> G: """Render the pie chart.""" result = G() # Calculate center and radius cx = self.width / 2 cy = self.height / 2 radius = min(self.width, self.height) / 2 * 0.8 # Get data and labels (use stored values, not x_data which is synthetic) data = self._pie_data labels = self._pie_labels or [str(i) for i in range(len(data))] total = sum(data) current_angle = self.start_angle # Render each slice for i, (value, label) in enumerate(zip(data, labels)): angle = (value / total) * 360 start_angle = current_angle end_angle = current_angle + angle # Calculate explode offset explode_offset = self.explode[i] if i < len(self.explode) else 0 transform = "" if explode_offset > 0: # Angle to midpoint of slice slice_angle = (start_angle + end_angle) / 2 slice_rad = math.radians(slice_angle - 90) offset_x = explode_offset * math.cos(slice_rad) offset_y = explode_offset * math.sin(slice_rad) transform = f"translate({offset_x}, {offset_y})" # Handle 100% single-slice edge case if angle >= 359.9: path_data = self._get_full_circle_path(cx, cy, radius) slice_path = Path( d=path_data, fill=self.colors[i % len(self.colors)], fill_rule="evenodd" if self.inner_radius > 0 else "nonzero", opacity=0.8, ) else: path_data = self._get_slice_path(cx, cy, radius, start_angle, end_angle) slice_path = Path( d=path_data, fill=self.colors[i % len(self.colors)], opacity=0.8, ) # Wrap in group with transform if exploded if transform: slice_g = G(transform=transform) slice_g.add_child(slice_path) result.add_child(slice_g) else: result.add_child(slice_path) # Add label inside slice with color-aware text label_angle = (start_angle + end_angle) / 2 label_rad = math.radians(label_angle - 90) # Position label in the middle of the ring for donut, 60% for regular pie if self.inner_radius > 0: actual_inner_radius = radius * self.inner_radius # Place label in the middle of the ring label_radius = (actual_inner_radius + radius) / 2 else: label_radius = radius * 0.6 label_x = cx + label_radius * math.cos(label_rad) label_y = cy + label_radius * math.sin(label_rad) # Use contrast-aware text color slice_color = self.colors[i % len(self.colors)] text_color = get_contrast_color(slice_color) label_text = Text( x=label_x, y=label_y, text=str(label), fill=text_color, font_size=14, font_family="Helvetica", text_anchor="middle", dominant_baseline="middle", ) result.add_child(label_text) current_angle = end_angle return result