|
4 | 4 | """ |
5 | 5 |
|
6 | 6 | import math |
7 | | -from typing import TYPE_CHECKING |
8 | 7 |
|
9 | 8 | import pandas as pd |
| 9 | +from bokeh.io import export_png |
10 | 10 | from bokeh.models import ColumnDataSource, Label, Legend, LegendItem |
11 | 11 | from bokeh.plotting import figure |
12 | 12 |
|
13 | 13 |
|
14 | | -if TYPE_CHECKING: |
15 | | - from bokeh.plotting import figure as Figure |
16 | | - |
17 | 14 | # PyPlots.ai style colors |
18 | | -PYPLOTS_COLORS = [ |
| 15 | +COLORS = [ |
19 | 16 | "#306998", # Python Blue |
20 | 17 | "#FFD43B", # Python Yellow |
21 | 18 | "#DC2626", # Signal Red |
|
24 | 21 | "#F97316", # Orange |
25 | 22 | ] |
26 | 23 |
|
27 | | - |
28 | | -def create_plot( |
29 | | - data: pd.DataFrame, |
30 | | - category: str, |
31 | | - value: str, |
32 | | - title: str | None = None, |
33 | | - colors: list[str] | None = None, |
34 | | - startangle: float = 90, |
35 | | - legend: bool = True, |
36 | | - legend_loc: str = "right", |
37 | | - **kwargs, |
38 | | -) -> "Figure": |
39 | | - """ |
40 | | - Create a basic pie chart using Bokeh wedge glyphs. |
41 | | -
|
42 | | - Bokeh does not have a native pie chart method, so this implementation |
43 | | - uses wedge glyphs to construct the pie chart manually. |
44 | | -
|
45 | | - Args: |
46 | | - data: Input DataFrame containing category and value columns |
47 | | - category: Column name for category labels (slice names) |
48 | | - value: Column name for numeric values (slice sizes) |
49 | | - title: Plot title (optional) |
50 | | - colors: Custom color palette for slices (defaults to PyPlots colors) |
51 | | - startangle: Starting angle for first slice in degrees (default: 90) |
52 | | - legend: Whether to display legend (default: True) |
53 | | - legend_loc: Legend location - 'right', 'left', 'above', 'below' (default: 'right') |
54 | | - **kwargs: Additional parameters passed to figure |
55 | | -
|
56 | | - Returns: |
57 | | - Bokeh figure object |
58 | | -
|
59 | | - Raises: |
60 | | - ValueError: If data is empty or values are all zero/negative |
61 | | - KeyError: If required columns not found in data |
62 | | -
|
63 | | - Example: |
64 | | - >>> data = pd.DataFrame({ |
65 | | - ... 'category': ['A', 'B', 'C'], |
66 | | - ... 'value': [30, 50, 20] |
67 | | - ... }) |
68 | | - >>> fig = create_plot(data, 'category', 'value', title='Distribution') |
69 | | - """ |
70 | | - # Input validation |
71 | | - if data.empty: |
72 | | - raise ValueError("Data cannot be empty") |
73 | | - |
74 | | - for col in [category, value]: |
75 | | - if col not in data.columns: |
76 | | - available = ", ".join(data.columns) |
77 | | - raise KeyError(f"Column '{col}' not found. Available: {available}") |
78 | | - |
79 | | - # Validate numeric values |
80 | | - if not pd.api.types.is_numeric_dtype(data[value]): |
81 | | - raise ValueError(f"Column '{value}' must contain numeric values") |
82 | | - |
83 | | - if (data[value] < 0).any(): |
84 | | - raise ValueError("Pie chart values must be non-negative") |
85 | | - |
86 | | - total = data[value].sum() |
87 | | - if total == 0: |
88 | | - raise ValueError("Sum of values cannot be zero") |
89 | | - |
90 | | - # Prepare data |
91 | | - plot_data = data.copy() |
92 | | - plot_data["angle"] = plot_data[value] / total * 2 * math.pi |
93 | | - plot_data["percentage"] = plot_data[value] / total * 100 |
94 | | - |
95 | | - # Calculate cumulative angles for wedge positioning |
96 | | - plot_data["end_angle"] = plot_data["angle"].cumsum() |
97 | | - plot_data["start_angle"] = plot_data["end_angle"] - plot_data["angle"] |
98 | | - |
99 | | - # Apply start angle offset (convert degrees to radians, adjust for Bokeh's coordinate system) |
100 | | - start_rad = math.radians(startangle - 90) |
101 | | - plot_data["start_angle"] = plot_data["start_angle"] + start_rad |
102 | | - plot_data["end_angle"] = plot_data["end_angle"] + start_rad |
103 | | - |
104 | | - # Assign colors |
105 | | - if colors is None: |
106 | | - colors = PYPLOTS_COLORS |
107 | | - # Cycle through colors if more categories than colors |
108 | | - num_categories = len(plot_data) |
109 | | - plot_data["color"] = [colors[i % len(colors)] for i in range(num_categories)] |
110 | | - |
111 | | - # Create ColumnDataSource |
112 | | - source = ColumnDataSource(plot_data) |
113 | | - |
114 | | - # Create figure - use range to ensure circular aspect ratio |
115 | | - # Set frame dimensions to maintain 16:9 overall but circular pie |
116 | | - fig_width = kwargs.get("width", 1600) |
117 | | - fig_height = kwargs.get("height", 900) |
118 | | - |
119 | | - p = figure( |
120 | | - width=fig_width, |
121 | | - height=fig_height, |
122 | | - title=title, |
123 | | - tools="hover", |
124 | | - tooltips=[(category.capitalize(), f"@{category}"), ("Value", f"@{value}"), ("Percentage", "@percentage{0.1}%")], |
125 | | - x_range=(-1.2, 2.0 if legend else 1.2), |
126 | | - y_range=(-1.2, 1.2), |
127 | | - ) |
128 | | - |
129 | | - # Draw wedges (pie slices) |
130 | | - renderers = p.wedge( |
131 | | - x=0, |
132 | | - y=0, |
133 | | - radius=0.9, |
134 | | - start_angle="start_angle", |
135 | | - end_angle="end_angle", |
136 | | - line_color="white", |
137 | | - line_width=2, |
138 | | - fill_color="color", |
139 | | - source=source, |
140 | | - ) |
141 | | - |
142 | | - # Add percentage labels inside slices |
143 | | - for _, row in plot_data.iterrows(): |
144 | | - # Calculate label position at middle of wedge, 60% from center |
145 | | - mid_angle = (row["start_angle"] + row["end_angle"]) / 2 |
146 | | - label_radius = 0.55 |
147 | | - |
148 | | - x = label_radius * math.cos(mid_angle) |
149 | | - y = label_radius * math.sin(mid_angle) |
150 | | - |
151 | | - # Only show percentage label if slice is large enough |
152 | | - if row["percentage"] >= 5: |
153 | | - label = Label( |
154 | | - x=x, |
155 | | - y=y, |
156 | | - text=f"{row['percentage']:.1f}%", |
157 | | - text_font_size="14pt", |
158 | | - text_align="center", |
159 | | - text_baseline="middle", |
160 | | - text_color="white" if row["percentage"] >= 10 else "black", |
161 | | - ) |
162 | | - p.add_layout(label) |
163 | | - |
164 | | - # Configure legend |
165 | | - if legend: |
166 | | - legend_items = [] |
167 | | - for i, cat in enumerate(plot_data[category]): |
168 | | - legend_items.append(LegendItem(label=str(cat), renderers=[renderers], index=i)) |
169 | | - |
170 | | - leg = Legend( |
171 | | - items=legend_items, |
172 | | - location="center", |
173 | | - label_text_font_size="16pt", |
174 | | - background_fill_color="white", |
175 | | - background_fill_alpha=1.0, |
176 | | - border_line_color="black", |
177 | | - border_line_width=1, |
| 24 | +# Data |
| 25 | +data = pd.DataFrame( |
| 26 | + {"category": ["Product A", "Product B", "Product C", "Product D", "Other"], "value": [35, 25, 20, 15, 5]} |
| 27 | +) |
| 28 | + |
| 29 | +# Calculate angles for pie slices |
| 30 | +total = data["value"].sum() |
| 31 | +data["angle"] = data["value"] / total * 2 * math.pi |
| 32 | +data["percentage"] = data["value"] / total * 100 |
| 33 | + |
| 34 | +# Calculate cumulative angles for wedge positioning |
| 35 | +data["end_angle"] = data["angle"].cumsum() |
| 36 | +data["start_angle"] = data["end_angle"] - data["angle"] |
| 37 | + |
| 38 | +# Apply start angle offset (start from top, 90 degrees) |
| 39 | +start_rad = math.radians(90 - 90) # Adjust for Bokeh coordinate system |
| 40 | +data["start_angle"] = data["start_angle"] + start_rad |
| 41 | +data["end_angle"] = data["end_angle"] + start_rad |
| 42 | + |
| 43 | +# Assign colors (cycle if more categories than colors) |
| 44 | +data["color"] = [COLORS[i % len(COLORS)] for i in range(len(data))] |
| 45 | + |
| 46 | +# Create ColumnDataSource |
| 47 | +source = ColumnDataSource(data) |
| 48 | + |
| 49 | +# Create figure - 4800 x 2700 px as per style guide |
| 50 | +p = figure( |
| 51 | + width=4800, |
| 52 | + height=2700, |
| 53 | + title="Market Share Distribution", |
| 54 | + tools="hover", |
| 55 | + tooltips=[("Category", "@category"), ("Value", "@value"), ("Percentage", "@percentage{0.1}%")], |
| 56 | + x_range=(-1.2, 2.0), |
| 57 | + y_range=(-1.2, 1.2), |
| 58 | +) |
| 59 | + |
| 60 | +# Draw wedges (pie slices) |
| 61 | +renderers = p.wedge( |
| 62 | + x=0, |
| 63 | + y=0, |
| 64 | + radius=0.9, |
| 65 | + start_angle="start_angle", |
| 66 | + end_angle="end_angle", |
| 67 | + line_color="white", |
| 68 | + line_width=2, |
| 69 | + fill_color="color", |
| 70 | + source=source, |
| 71 | +) |
| 72 | + |
| 73 | +# Add percentage labels inside slices |
| 74 | +for _, row in data.iterrows(): |
| 75 | + mid_angle = (row["start_angle"] + row["end_angle"]) / 2 |
| 76 | + label_radius = 0.55 |
| 77 | + |
| 78 | + x = label_radius * math.cos(mid_angle) |
| 79 | + y = label_radius * math.sin(mid_angle) |
| 80 | + |
| 81 | + # Only show label if slice is large enough |
| 82 | + if row["percentage"] >= 5: |
| 83 | + label = Label( |
| 84 | + x=x, |
| 85 | + y=y, |
| 86 | + text=f"{row['percentage']:.1f}%", |
| 87 | + text_font_size="48pt", |
| 88 | + text_align="center", |
| 89 | + text_baseline="middle", |
| 90 | + text_color="white" if row["percentage"] >= 10 else "black", |
178 | 91 | ) |
179 | | - |
180 | | - p.add_layout(leg, legend_loc) |
181 | | - |
182 | | - # Style configuration |
183 | | - p.axis.visible = False |
184 | | - p.grid.visible = False |
185 | | - p.outline_line_color = None |
186 | | - |
187 | | - # Title styling |
188 | | - if title: |
189 | | - p.title.text_font_size = "20pt" |
190 | | - p.title.align = "center" |
191 | | - |
192 | | - # Background |
193 | | - p.background_fill_color = "white" |
194 | | - |
195 | | - return p |
196 | | - |
197 | | - |
198 | | -if __name__ == "__main__": |
199 | | - # Sample data for testing |
200 | | - sample_data = pd.DataFrame( |
201 | | - {"category": ["Product A", "Product B", "Product C", "Product D", "Other"], "value": [35, 25, 20, 15, 5]} |
202 | | - ) |
203 | | - |
204 | | - # Create plot |
205 | | - fig = create_plot(sample_data, "category", "value", title="Market Share Distribution") |
206 | | - |
207 | | - # Save - try PNG first, fall back to HTML if selenium not available |
208 | | - try: |
209 | | - from bokeh.io import export_png |
210 | | - |
211 | | - export_png(fig, filename="plot.png") |
212 | | - print("Plot saved to plot.png") |
213 | | - except RuntimeError as e: |
214 | | - if "selenium" in str(e).lower(): |
215 | | - from bokeh.io import output_file, save |
216 | | - |
217 | | - output_file("plot.html") |
218 | | - save(fig) |
219 | | - print("Plot saved to plot.html (selenium not available for PNG export)") |
220 | | - else: |
221 | | - raise |
| 92 | + p.add_layout(label) |
| 93 | + |
| 94 | +# Create legend |
| 95 | +legend_items = [] |
| 96 | +for i, cat in enumerate(data["category"]): |
| 97 | + legend_items.append(LegendItem(label=str(cat), renderers=[renderers], index=i)) |
| 98 | + |
| 99 | +leg = Legend( |
| 100 | + items=legend_items, |
| 101 | + location="center", |
| 102 | + label_text_font_size="48pt", |
| 103 | + background_fill_color="white", |
| 104 | + background_fill_alpha=1.0, |
| 105 | + border_line_color="black", |
| 106 | + border_line_width=1, |
| 107 | +) |
| 108 | +p.add_layout(leg, "right") |
| 109 | + |
| 110 | +# Style configuration |
| 111 | +p.axis.visible = False |
| 112 | +p.grid.visible = False |
| 113 | +p.outline_line_color = None |
| 114 | + |
| 115 | +# Title styling |
| 116 | +p.title.text_font_size = "60pt" |
| 117 | +p.title.align = "center" |
| 118 | + |
| 119 | +# Background |
| 120 | +p.background_fill_color = "white" |
| 121 | + |
| 122 | +# Save |
| 123 | +export_png(p, filename="plot.png") |
0 commit comments