|
1 | 1 | """ |
2 | 2 | pie-basic: Basic Pie Chart |
3 | 3 | Library: highcharts |
4 | | -
|
5 | | -A fundamental pie chart that visualizes proportions and percentages of categorical data |
6 | | -as slices of a circular chart. Each slice represents a category's share of the whole. |
7 | | -
|
8 | | -Note: Highcharts requires a license for commercial use. |
9 | 4 | """ |
10 | 5 |
|
11 | | -from typing import Optional |
| 6 | +import tempfile |
| 7 | +import time |
| 8 | +import urllib.request |
| 9 | +from pathlib import Path |
12 | 10 |
|
13 | 11 | import pandas as pd |
14 | 12 | from highcharts_core.chart import Chart |
15 | 13 | from highcharts_core.options import HighchartsOptions |
16 | 14 | from highcharts_core.options.series.pie import PieSeries |
| 15 | +from selenium import webdriver |
| 16 | +from selenium.webdriver.chrome.options import Options |
| 17 | + |
17 | 18 |
|
| 19 | +# Data (from spec) |
| 20 | +data = pd.DataFrame( |
| 21 | + {"category": ["Product A", "Product B", "Product C", "Product D", "Other"], "value": [35, 25, 20, 15, 5]} |
| 22 | +) |
18 | 23 |
|
19 | 24 | # Style guide colors |
20 | | -PYPLOTS_COLORS = [ |
21 | | - "#306998", # Python Blue (Primary) |
| 25 | +COLORS = [ |
| 26 | + "#306998", # Python Blue |
22 | 27 | "#FFD43B", # Python Yellow |
23 | 28 | "#DC2626", # Signal Red |
24 | 29 | "#059669", # Teal Green |
25 | 30 | "#8B5CF6", # Violet |
26 | 31 | "#F97316", # Orange |
27 | 32 | ] |
28 | 33 |
|
29 | | - |
30 | | -def create_plot( |
31 | | - data: pd.DataFrame, |
32 | | - category: str, |
33 | | - value: str, |
34 | | - figsize: tuple[int, int] = (10, 8), |
35 | | - title: Optional[str] = None, |
36 | | - colors: Optional[list[str]] = None, |
37 | | - startangle: float = 90, |
38 | | - autopct: str = "%1.1f%%", |
39 | | - explode: Optional[list[float]] = None, |
40 | | - shadow: bool = False, |
41 | | - labels: Optional[list[str]] = None, |
42 | | - legend: bool = True, |
43 | | - legend_loc: str = "best", |
44 | | - width: int = 1600, |
45 | | - height: int = 900, |
46 | | - **kwargs, |
47 | | -) -> Chart: |
48 | | - """ |
49 | | - Create a basic pie chart from DataFrame. |
50 | | -
|
51 | | - Args: |
52 | | - data: Input DataFrame with categorical and numeric data |
53 | | - category: Column name for category names (slice labels) |
54 | | - value: Column name for numeric values (slice proportions) |
55 | | - figsize: Figure size as (width, height) in inches (legacy, use width/height instead) |
56 | | - title: Plot title |
57 | | - colors: Custom color palette for slices (defaults to PyPlots style guide colors) |
58 | | - startangle: Starting angle for first slice in degrees (from positive x-axis) |
59 | | - autopct: Format string for percentage labels |
60 | | - explode: Offset distances for each slice (0-0.1 typical) |
61 | | - shadow: Add shadow effect for 3D appearance |
62 | | - labels: Custom labels (defaults to category names) |
63 | | - legend: Whether to display legend |
64 | | - legend_loc: Legend location (e.g., 'best', 'right', 'left') |
65 | | - width: Figure width in pixels (default: 1600) |
66 | | - height: Figure height in pixels (default: 900) |
67 | | - **kwargs: Additional parameters passed to chart options |
68 | | -
|
69 | | - Returns: |
70 | | - Highcharts Chart object |
71 | | -
|
72 | | - Raises: |
73 | | - ValueError: If data is empty or contains negative values |
74 | | - KeyError: If required columns are not found in data |
75 | | -
|
76 | | - Example: |
77 | | - >>> data = pd.DataFrame({ |
78 | | - ... 'category': ['Product A', 'Product B', 'Product C'], |
79 | | - ... 'value': [35, 25, 40] |
80 | | - ... }) |
81 | | - >>> chart = create_plot(data, 'category', 'value', title='Market Share') |
82 | | - """ |
83 | | - # Input validation |
84 | | - if data.empty: |
85 | | - raise ValueError("Data cannot be empty") |
86 | | - |
87 | | - for col in [category, value]: |
88 | | - if col not in data.columns: |
89 | | - available = ", ".join(data.columns.tolist()) |
90 | | - raise KeyError(f"Column '{col}' not found. Available: {available}") |
91 | | - |
92 | | - # Validate non-negative values |
93 | | - if (data[value] < 0).any(): |
94 | | - raise ValueError("Pie chart values must be non-negative") |
95 | | - |
96 | | - # Check if all values sum to zero |
97 | | - if data[value].sum() == 0: |
98 | | - raise ValueError("Sum of values cannot be zero") |
99 | | - |
100 | | - # Get colors (use provided or default to PyPlots style guide) |
101 | | - slice_colors = colors if colors is not None else PYPLOTS_COLORS |
102 | | - |
103 | | - # Get labels (use provided or default to category names) |
104 | | - slice_labels = labels if labels is not None else data[category].tolist() |
105 | | - |
106 | | - # Create chart with container ID for rendering |
107 | | - chart = Chart(container="container") |
108 | | - chart.options = HighchartsOptions() |
109 | | - |
110 | | - # Chart configuration |
111 | | - chart.options.chart = {"type": "pie", "width": width, "height": height, "backgroundColor": "#ffffff"} |
112 | | - |
113 | | - # Title with style guide typography |
114 | | - if title: |
115 | | - chart.options.title = { |
116 | | - "text": title, |
117 | | - "style": { |
118 | | - "fontSize": "20px", |
119 | | - "fontWeight": "600", |
120 | | - "fontFamily": "Inter, DejaVu Sans, Arial, Helvetica, sans-serif", |
121 | | - }, |
122 | | - } |
123 | | - else: |
124 | | - chart.options.title = {"text": None} |
125 | | - |
126 | | - # Build data points for pie series |
127 | | - pie_data = [] |
128 | | - for i, (cat, val) in enumerate(zip(data[category].tolist(), data[value].tolist(), strict=True)): |
129 | | - point = { |
130 | | - "name": slice_labels[i] if i < len(slice_labels) else cat, |
131 | | - "y": val, |
132 | | - "color": slice_colors[i % len(slice_colors)], |
133 | | - } |
134 | | - |
135 | | - # Apply explode if provided |
136 | | - if explode is not None and i < len(explode) and explode[i] > 0: |
137 | | - point["sliced"] = True |
138 | | - point["selected"] = True |
139 | | - |
140 | | - pie_data.append(point) |
141 | | - |
142 | | - # Create pie series |
143 | | - series = PieSeries() |
144 | | - series.data = pie_data |
145 | | - series.name = value |
146 | | - |
147 | | - # Configure data labels to show percentages |
148 | | - # Parse autopct format for decimal places (e.g., '%1.1f%%' -> 1 decimal) |
149 | | - decimal_places = 1 |
150 | | - if autopct and "." in autopct: |
151 | | - try: |
152 | | - decimal_places = int(autopct.split(".")[1][0]) |
153 | | - except (IndexError, ValueError): |
154 | | - decimal_places = 1 |
155 | | - |
156 | | - # Pie series options |
157 | | - series.show_in_legend = legend |
158 | | - series.start_angle = startangle |
159 | | - series.shadow = shadow |
160 | | - |
161 | | - # Data labels configuration |
162 | | - series.data_labels = { |
163 | | - "enabled": True, |
164 | | - "format": f"{{point.percentage:.{decimal_places}f}}%", |
165 | | - "distance": 20, |
166 | | - "style": { |
167 | | - "fontSize": "14px", |
168 | | - "fontWeight": "normal", |
169 | | - "fontFamily": "Inter, DejaVu Sans, Arial, Helvetica, sans-serif", |
170 | | - "textOutline": "2px white", |
171 | | - }, |
172 | | - } |
173 | | - |
174 | | - chart.add_series(series) |
175 | | - |
176 | | - # Plot options for pie |
177 | | - chart.options.plot_options = { |
178 | | - "pie": { |
179 | | - "allowPointSelect": True, |
180 | | - "cursor": "pointer", |
181 | | - "showInLegend": legend, |
182 | | - "startAngle": startangle, |
183 | | - "shadow": shadow, |
184 | | - "center": ["50%", "50%"], |
185 | | - "size": "75%", |
186 | | - } |
| 34 | +# Create chart |
| 35 | +chart = Chart(container="container") |
| 36 | +chart.options = HighchartsOptions() |
| 37 | + |
| 38 | +# Chart configuration (4800 x 2700 px per style guide) |
| 39 | +chart.options.chart = {"type": "pie", "width": 4800, "height": 2700, "backgroundColor": "#ffffff"} |
| 40 | + |
| 41 | +# Title |
| 42 | +chart.options.title = {"text": "Market Share Distribution", "style": {"fontSize": "48px", "fontWeight": "600"}} |
| 43 | + |
| 44 | +# Build pie data with colors |
| 45 | +pie_data = [] |
| 46 | +for i, row in data.iterrows(): |
| 47 | + pie_data.append({"name": row["category"], "y": row["value"], "color": COLORS[i % len(COLORS)]}) |
| 48 | + |
| 49 | +# Create pie series |
| 50 | +series = PieSeries() |
| 51 | +series.data = pie_data |
| 52 | +series.name = "Market Share" |
| 53 | +series.show_in_legend = True |
| 54 | + |
| 55 | +# Data labels with percentages |
| 56 | +series.data_labels = { |
| 57 | + "enabled": True, |
| 58 | + "format": "{point.name}: {point.percentage:.1f}%", |
| 59 | + "distance": 40, |
| 60 | + "style": {"fontSize": "32px", "fontWeight": "normal", "textOutline": "3px white"}, |
| 61 | +} |
| 62 | + |
| 63 | +chart.add_series(series) |
| 64 | + |
| 65 | +# Plot options for pie |
| 66 | +chart.options.plot_options = { |
| 67 | + "pie": { |
| 68 | + "allowPointSelect": True, |
| 69 | + "cursor": "pointer", |
| 70 | + "showInLegend": True, |
| 71 | + "center": ["50%", "50%"], |
| 72 | + "size": "70%", |
187 | 73 | } |
188 | | - |
189 | | - # Legend configuration |
190 | | - if legend: |
191 | | - # Map legend_loc to Highcharts position |
192 | | - legend_config = { |
193 | | - "enabled": True, |
194 | | - "align": "right", |
195 | | - "verticalAlign": "middle", |
196 | | - "layout": "vertical", |
197 | | - "itemStyle": {"fontSize": "16px", "fontFamily": "Inter, DejaVu Sans, Arial, Helvetica, sans-serif"}, |
198 | | - "backgroundColor": "#ffffff", |
199 | | - "borderWidth": 1, |
200 | | - "borderRadius": 5, |
201 | | - } |
202 | | - |
203 | | - if legend_loc in ["left"]: |
204 | | - legend_config["align"] = "left" |
205 | | - elif legend_loc in ["right"]: |
206 | | - legend_config["align"] = "right" |
207 | | - elif legend_loc in ["top", "upper center"]: |
208 | | - legend_config["align"] = "center" |
209 | | - legend_config["verticalAlign"] = "top" |
210 | | - legend_config["layout"] = "horizontal" |
211 | | - elif legend_loc in ["bottom", "lower center"]: |
212 | | - legend_config["align"] = "center" |
213 | | - legend_config["verticalAlign"] = "bottom" |
214 | | - legend_config["layout"] = "horizontal" |
215 | | - |
216 | | - chart.options.legend = legend_config |
217 | | - else: |
218 | | - chart.options.legend = {"enabled": False} |
219 | | - |
220 | | - # Tooltip configuration |
221 | | - chart.options.tooltip = { |
222 | | - "pointFormat": "<b>{point.percentage:.1f}%</b><br/>Value: {point.y}", |
223 | | - "style": {"fontSize": "14px", "fontFamily": "Inter, DejaVu Sans, Arial, Helvetica, sans-serif"}, |
224 | | - } |
225 | | - |
226 | | - # Credits |
227 | | - chart.options.credits = {"enabled": False} |
228 | | - |
229 | | - return chart |
230 | | - |
231 | | - |
232 | | -if __name__ == "__main__": |
233 | | - import tempfile |
234 | | - import time |
235 | | - import urllib.request |
236 | | - from pathlib import Path |
237 | | - |
238 | | - from selenium import webdriver |
239 | | - from selenium.webdriver.chrome.options import Options |
240 | | - |
241 | | - # Sample data for testing (from spec) |
242 | | - sample_data = pd.DataFrame( |
243 | | - {"category": ["Product A", "Product B", "Product C", "Product D", "Other"], "value": [35, 25, 20, 15, 5]} |
244 | | - ) |
245 | | - |
246 | | - # Create plot |
247 | | - chart = create_plot(sample_data, "category", "value", title="Market Share Distribution") |
248 | | - |
249 | | - # Download Highcharts JS (required for headless Chrome which can't load CDN) |
250 | | - highcharts_url = "https://code.highcharts.com/highcharts.js" |
251 | | - with urllib.request.urlopen(highcharts_url, timeout=30) as response: |
252 | | - highcharts_js = response.read().decode("utf-8") |
253 | | - |
254 | | - # Export to PNG via Selenium screenshot |
255 | | - html_str = chart.to_js_literal() |
256 | | - html_content = f"""<!DOCTYPE html> |
| 74 | +} |
| 75 | + |
| 76 | +# Legend configuration |
| 77 | +chart.options.legend = { |
| 78 | + "enabled": True, |
| 79 | + "align": "right", |
| 80 | + "verticalAlign": "middle", |
| 81 | + "layout": "vertical", |
| 82 | + "itemStyle": {"fontSize": "32px"}, |
| 83 | +} |
| 84 | + |
| 85 | +# Tooltip |
| 86 | +chart.options.tooltip = { |
| 87 | + "pointFormat": "<b>{point.percentage:.1f}%</b><br/>Value: {point.y}", |
| 88 | + "style": {"fontSize": "28px"}, |
| 89 | +} |
| 90 | + |
| 91 | +# Disable credits |
| 92 | +chart.options.credits = {"enabled": False} |
| 93 | + |
| 94 | +# Download Highcharts JS (required for headless Chrome) |
| 95 | +highcharts_url = "https://code.highcharts.com/highcharts.js" |
| 96 | +with urllib.request.urlopen(highcharts_url, timeout=30) as response: |
| 97 | + highcharts_js = response.read().decode("utf-8") |
| 98 | + |
| 99 | +# Generate HTML with inline scripts |
| 100 | +html_str = chart.to_js_literal() |
| 101 | +html_content = f"""<!DOCTYPE html> |
257 | 102 | <html> |
258 | 103 | <head> |
259 | 104 | <meta charset="utf-8"> |
260 | 105 | <script>{highcharts_js}</script> |
261 | 106 | </head> |
262 | 107 | <body style="margin:0;"> |
263 | | - <div id="container" style="width: 1600px; height: 900px;"></div> |
| 108 | + <div id="container" style="width: 4800px; height: 2700px;"></div> |
264 | 109 | <script>{html_str}</script> |
265 | 110 | </body> |
266 | 111 | </html>""" |
267 | 112 |
|
268 | | - # Write temp HTML and take screenshot |
269 | | - with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f: |
270 | | - f.write(html_content) |
271 | | - temp_path = f.name |
272 | | - |
273 | | - chrome_options = Options() |
274 | | - chrome_options.add_argument("--headless") |
275 | | - chrome_options.add_argument("--no-sandbox") |
276 | | - chrome_options.add_argument("--disable-dev-shm-usage") |
277 | | - chrome_options.add_argument("--disable-gpu") |
278 | | - chrome_options.add_argument("--window-size=1600,900") |
279 | | - |
280 | | - driver = webdriver.Chrome(options=chrome_options) |
281 | | - driver.get(f"file:///{temp_path}") |
282 | | - time.sleep(5) # Wait for chart to render |
283 | | - driver.save_screenshot("plot.png") |
284 | | - driver.quit() |
285 | | - |
286 | | - Path(temp_path).unlink() # Clean up temp file |
287 | | - print("Plot saved to plot.png") |
| 113 | +# Write temp HTML |
| 114 | +with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f: |
| 115 | + f.write(html_content) |
| 116 | + temp_path = f.name |
| 117 | + |
| 118 | +# Take screenshot with Selenium |
| 119 | +chrome_options = Options() |
| 120 | +chrome_options.add_argument("--headless") |
| 121 | +chrome_options.add_argument("--no-sandbox") |
| 122 | +chrome_options.add_argument("--disable-dev-shm-usage") |
| 123 | +chrome_options.add_argument("--disable-gpu") |
| 124 | +chrome_options.add_argument("--window-size=5000,3000") |
| 125 | + |
| 126 | +driver = webdriver.Chrome(options=chrome_options) |
| 127 | +driver.get(f"file://{temp_path}") |
| 128 | +time.sleep(5) # Wait for chart to render |
| 129 | + |
| 130 | +# Screenshot the chart container element for exact dimensions |
| 131 | +container = driver.find_element("id", "container") |
| 132 | +container.screenshot("plot.png") |
| 133 | +driver.quit() |
| 134 | + |
| 135 | +# Clean up temp file |
| 136 | +Path(temp_path).unlink() |
0 commit comments