Skip to content

Commit 385722f

Browse files
feat(bokeh): implement pie-basic
Simplify bokeh pie chart implementation to follow KISS style: - Remove function-based approach in favor of sequential script - Remove type hints and docstrings - Use 4800x2700 px output as per style guide - Scale font sizes appropriately for high-res output - Use PyPlots color palette
1 parent 27357ca commit 385722f

1 file changed

Lines changed: 101 additions & 199 deletions

File tree

plots/bokeh/custom/pie-basic/default.py

Lines changed: 101 additions & 199 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,15 @@
44
"""
55

66
import math
7-
from typing import TYPE_CHECKING
87

98
import pandas as pd
9+
from bokeh.io import export_png
1010
from bokeh.models import ColumnDataSource, Label, Legend, LegendItem
1111
from bokeh.plotting import figure
1212

1313

14-
if TYPE_CHECKING:
15-
from bokeh.plotting import figure as Figure
16-
1714
# PyPlots.ai style colors
18-
PYPLOTS_COLORS = [
15+
COLORS = [
1916
"#306998", # Python Blue
2017
"#FFD43B", # Python Yellow
2118
"#DC2626", # Signal Red
@@ -24,198 +21,103 @@
2421
"#F97316", # Orange
2522
]
2623

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",
17891
)
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

Comments
 (0)