Skip to content

Commit 89dbc60

Browse files
feat(bokeh): implement pie-drilldown (#3127)
## Implementation: `pie-drilldown` - bokeh Implements the **bokeh** version of `pie-drilldown`. **File:** `plots/pie-drilldown/implementations/bokeh.py` **Parent Issue:** #3072 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20620528667)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent c9dc912 commit 89dbc60

2 files changed

Lines changed: 393 additions & 0 deletions

File tree

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
"""pyplots.ai
2+
pie-drilldown: Drilldown Pie Chart with Click Navigation
3+
Library: bokeh 3.8.1 | Python 3.13.11
4+
Quality: 72/100 | Created: 2025-12-31
5+
"""
6+
7+
import json
8+
from math import pi
9+
10+
import numpy as np
11+
from bokeh.io import export_png, save
12+
from bokeh.layouts import column
13+
from bokeh.models import ColumnDataSource, CustomJS, Div, Label, TapTool
14+
from bokeh.plotting import figure
15+
from bokeh.resources import INLINE
16+
17+
18+
# Hierarchical data: Company expense breakdown
19+
# Level 0: Total
20+
# Level 1: Departments
21+
# Level 2: Categories within departments
22+
# Level 3: Specific items
23+
24+
hierarchy = {
25+
"root": {"name": "Total Expenses", "children": ["operations", "marketing", "research", "hr"]},
26+
"operations": {"name": "Operations", "parent": "root", "children": ["facilities", "logistics", "it_infra"]},
27+
"marketing": {"name": "Marketing", "parent": "root", "children": ["digital", "events", "content"]},
28+
"research": {"name": "Research", "parent": "root", "children": ["lab_equip", "materials", "personnel"]},
29+
"hr": {"name": "Human Resources", "parent": "root", "children": ["recruitment", "training", "benefits"]},
30+
# Operations subcategories
31+
"facilities": {"name": "Facilities", "parent": "operations", "children": ["rent", "utilities", "maintenance"]},
32+
"logistics": {"name": "Logistics", "parent": "operations", "children": ["shipping", "warehousing"]},
33+
"it_infra": {"name": "IT Infrastructure", "parent": "operations", "children": ["servers", "software", "support"]},
34+
# Marketing subcategories
35+
"digital": {"name": "Digital Marketing", "parent": "marketing", "children": ["ads", "seo", "social"]},
36+
"events": {"name": "Events", "parent": "marketing", "value": 180000},
37+
"content": {"name": "Content", "parent": "marketing", "value": 120000},
38+
# Research subcategories
39+
"lab_equip": {"name": "Lab Equipment", "parent": "research", "value": 350000},
40+
"materials": {"name": "Materials", "parent": "research", "value": 200000},
41+
"personnel": {"name": "Personnel", "parent": "research", "value": 450000},
42+
# HR subcategories
43+
"recruitment": {"name": "Recruitment", "parent": "hr", "value": 150000},
44+
"training": {"name": "Training", "parent": "hr", "value": 100000},
45+
"benefits": {"name": "Benefits", "parent": "hr", "value": 400000},
46+
# Facilities leaf nodes
47+
"rent": {"name": "Rent", "parent": "facilities", "value": 300000},
48+
"utilities": {"name": "Utilities", "parent": "facilities", "value": 80000},
49+
"maintenance": {"name": "Maintenance", "parent": "facilities", "value": 60000},
50+
# Logistics leaf nodes
51+
"shipping": {"name": "Shipping", "parent": "logistics", "value": 250000},
52+
"warehousing": {"name": "Warehousing", "parent": "logistics", "value": 180000},
53+
# IT Infrastructure leaf nodes
54+
"servers": {"name": "Servers", "parent": "it_infra", "value": 200000},
55+
"software": {"name": "Software", "parent": "it_infra", "value": 150000},
56+
"support": {"name": "Support", "parent": "it_infra", "value": 100000},
57+
# Digital Marketing leaf nodes
58+
"ads": {"name": "Advertising", "parent": "digital", "value": 400000},
59+
"seo": {"name": "SEO", "parent": "digital", "value": 80000},
60+
"social": {"name": "Social Media", "parent": "digital", "value": 120000},
61+
}
62+
63+
# Color palette - colorblind-safe (no red-green adjacency), high contrast
64+
# Using IBM's colorblind-safe palette and tableau-inspired colors
65+
colors = [
66+
"#0072B2", # Strong Blue (Operations)
67+
"#E69F00", # Orange/Amber (Marketing)
68+
"#CC79A7", # Pink/Magenta (Research)
69+
"#56B4E9", # Sky Blue (HR)
70+
"#009E73", # Teal Green
71+
"#D55E00", # Vermillion
72+
"#F0E442", # Yellow
73+
"#0072B2", # Blue (repeat for deeper levels)
74+
]
75+
76+
# Calculate values for root level children (inline, no helper functions)
77+
root_children = hierarchy["root"]["children"]
78+
names = []
79+
values = []
80+
ids = []
81+
has_children_list = []
82+
83+
for child_id in root_children:
84+
child = hierarchy[child_id]
85+
names.append(child["name"])
86+
ids.append(child_id)
87+
has_children_list.append("children" in child)
88+
# Calculate value: sum of all descendants
89+
if "value" in child:
90+
values.append(child["value"])
91+
else:
92+
# Sum values of all descendants
93+
stack = list(child.get("children", []))
94+
total_val = 0
95+
while stack:
96+
node_id = stack.pop()
97+
node = hierarchy[node_id]
98+
if "value" in node:
99+
total_val += node["value"]
100+
elif "children" in node:
101+
stack.extend(node["children"])
102+
values.append(total_val)
103+
104+
total = sum(values)
105+
percentages = [v / total * 100 for v in values]
106+
107+
# Calculate angles for pie wedges (clockwise from 12 o'clock)
108+
# Start from pi/2 (12 o'clock position) and go clockwise (negative direction)
109+
angles = [v / total * 2 * pi for v in values]
110+
start_angles = [pi / 2 - sum(angles[:i]) for i in range(len(angles))]
111+
end_angles = [pi / 2 - sum(angles[: i + 1]) for i in range(len(angles))]
112+
113+
# Assign colors to each slice
114+
slice_colors = colors[: len(names)]
115+
116+
# Create source data for wedges
117+
source = ColumnDataSource(
118+
data={
119+
"names": names,
120+
"values": values,
121+
"ids": ids,
122+
"has_children": has_children_list,
123+
"start_angle": start_angles,
124+
"end_angle": end_angles,
125+
"color": slice_colors,
126+
"percentage": percentages,
127+
"label": [f"{n}\n${v / 1000:.0f}K\n({p:.1f}%)" for n, v, p in zip(names, values, percentages, strict=True)],
128+
}
129+
)
130+
131+
# Label source for the center of each wedge
132+
# Use dynamic label radius - larger slices get inner labels, smaller ones get outer labels
133+
mid_angles = [(s + e) / 2 for s, e in zip(start_angles, end_angles, strict=True)]
134+
# Increase base radius so labels are more readable, especially for smaller slices
135+
label_radius_values = [0.55 if p >= 15 else 0.65 for p in percentages]
136+
label_x = [r * np.cos(a) for r, a in zip(label_radius_values, mid_angles, strict=True)]
137+
label_y = [r * np.sin(a) for r, a in zip(label_radius_values, mid_angles, strict=True)]
138+
139+
# Create more compact labels for smaller slices
140+
label_texts = []
141+
for n, v, p in zip(names, values, percentages, strict=True):
142+
if p >= 15:
143+
label_texts.append(f"{n}\n${v / 1000:.0f}K\n({p:.1f}%)")
144+
else:
145+
# Compact format for smaller slices
146+
label_texts.append(f"{n}\n${v / 1000:.0f}K ({p:.1f}%)")
147+
148+
label_source = ColumnDataSource(data={"x": label_x, "y": label_y, "text": label_texts})
149+
150+
# Create figure with extended y_range to fit breadcrumb and instruction text
151+
p = figure(
152+
width=3600,
153+
height=3600,
154+
title="pie-drilldown · bokeh · pyplots.ai",
155+
tools="tap,reset",
156+
toolbar_location=None,
157+
x_range=(-1.5, 1.5),
158+
y_range=(-1.6, 1.7),
159+
)
160+
161+
# Style the figure
162+
p.title.text_font_size = "48pt"
163+
p.title.align = "center"
164+
p.title.text_color = "#306998"
165+
p.axis.visible = False
166+
p.grid.visible = False
167+
p.outline_line_color = None
168+
p.background_fill_color = "#fafafa"
169+
170+
# Draw wedges using ColumnDataSource for proper color rendering
171+
wedges = p.wedge(
172+
x=0,
173+
y=0,
174+
radius=0.9,
175+
start_angle="start_angle",
176+
end_angle="end_angle",
177+
fill_color="color",
178+
line_color="white",
179+
line_width=4,
180+
source=source,
181+
)
182+
183+
# Add text shadow/outline for better readability on lighter slices
184+
# First layer: dark outline for contrast
185+
label_shadow = p.text(
186+
x="x",
187+
y="y",
188+
text="text",
189+
source=label_source,
190+
text_font_size="30pt",
191+
text_align="center",
192+
text_baseline="middle",
193+
text_color="#222222",
194+
text_font_style="bold",
195+
text_outline_color="#222222",
196+
)
197+
198+
# Add labels with larger font size for 3600x3600 canvas
199+
labels = p.text(
200+
x="x",
201+
y="y",
202+
text="text",
203+
source=label_source,
204+
text_font_size="30pt",
205+
text_align="center",
206+
text_baseline="middle",
207+
text_color="white",
208+
text_font_style="bold",
209+
)
210+
211+
# Add breadcrumb navigation label (visible in static PNG)
212+
breadcrumb_label = Label(
213+
x=0,
214+
y=1.45,
215+
text="Total Expenses",
216+
text_font_size="36pt",
217+
text_font_style="bold",
218+
text_color="#306998",
219+
text_align="center",
220+
text_baseline="middle",
221+
)
222+
p.add_layout(breadcrumb_label)
223+
224+
# Add clickable indicator text (visible in static PNG)
225+
click_indicator = Label(
226+
x=0,
227+
y=-1.35,
228+
text="Click a slice to drill down",
229+
text_font_size="28pt",
230+
text_color="#666666",
231+
text_align="center",
232+
text_baseline="middle",
233+
)
234+
p.add_layout(click_indicator)
235+
236+
# Breadcrumb navigation div for HTML version
237+
breadcrumb = Div(
238+
text='<div style="font-size: 32pt; font-family: Arial, sans-serif; color: #306998; '
239+
'padding: 20px; text-align: center;">'
240+
'<span style="cursor: pointer; color: #306998; font-weight: bold;">Total Expenses</span>'
241+
'<span style="color: #999; margin: 0 10px;"> | Click a slice to drill down</span>'
242+
"</div>",
243+
width=3600,
244+
height=100,
245+
)
246+
247+
# Store hierarchy data as JSON for JavaScript
248+
hierarchy_json = json.dumps(hierarchy)
249+
colors_json = json.dumps(colors)
250+
251+
# JavaScript callback for drilling down on click (uses main source for interactivity)
252+
callback = CustomJS(
253+
args={
254+
"source": source,
255+
"label_source": label_source,
256+
"breadcrumb": breadcrumb,
257+
"hierarchy_json": hierarchy_json,
258+
"colors_json": colors_json,
259+
},
260+
code="""
261+
const hierarchy = JSON.parse(hierarchy_json);
262+
const colors = JSON.parse(colors_json);
263+
264+
// Track navigation path
265+
if (!window.nav_path) {
266+
window.nav_path = ['root'];
267+
}
268+
269+
const indices = source.selected.indices;
270+
if (indices.length === 0) return;
271+
272+
const clicked_id = source.data['ids'][indices[0]];
273+
const clicked_node = hierarchy[clicked_id];
274+
275+
// Check if node has children (can drill down)
276+
if (!clicked_node.children || clicked_node.children.length === 0) {
277+
return;
278+
}
279+
280+
// Calculate value recursively
281+
function getValue(node_id) {
282+
const node = hierarchy[node_id];
283+
if (node.value !== undefined) return node.value;
284+
if (!node.children) return 0;
285+
return node.children.reduce((sum, child) => sum + getValue(child), 0);
286+
}
287+
288+
// Get children data
289+
const children = clicked_node.children;
290+
const names = children.map(id => hierarchy[id].name);
291+
const values = children.map(id => getValue(id));
292+
const total = values.reduce((a, b) => a + b, 0);
293+
const percentages = values.map(v => v / total * 100);
294+
const has_children = children.map(id => hierarchy[id].children !== undefined);
295+
296+
// Calculate angles (clockwise from 12 o'clock)
297+
const angles = values.map(v => v / total * 2 * Math.PI);
298+
const start_angles = [];
299+
const end_angles = [];
300+
let cumsum = Math.PI / 2;
301+
for (let i = 0; i < angles.length; i++) {
302+
start_angles.push(cumsum);
303+
cumsum -= angles[i];
304+
end_angles.push(cumsum);
305+
}
306+
307+
// Update source
308+
source.data['names'] = names;
309+
source.data['values'] = values;
310+
source.data['ids'] = children;
311+
source.data['has_children'] = has_children;
312+
source.data['start_angle'] = start_angles;
313+
source.data['end_angle'] = end_angles;
314+
source.data['color'] = colors.slice(0, names.length);
315+
source.data['percentage'] = percentages;
316+
source.data['label'] = names.map((n, i) =>
317+
n + '\\n$' + (values[i]/1000).toFixed(0) + 'K\\n(' + percentages[i].toFixed(1) + '%)'
318+
);
319+
320+
// Update label positions with dynamic radius for smaller slices
321+
const mid_angles = start_angles.map((s, i) => (s + end_angles[i]) / 2);
322+
const label_radii = percentages.map(p => p >= 15 ? 0.55 : 0.65);
323+
label_source.data['x'] = mid_angles.map((a, i) => label_radii[i] * Math.cos(a));
324+
label_source.data['y'] = mid_angles.map((a, i) => label_radii[i] * Math.sin(a));
325+
// Compact format for smaller slices
326+
label_source.data['text'] = names.map((n, i) =>
327+
percentages[i] >= 15
328+
? n + '\\n$' + (values[i]/1000).toFixed(0) + 'K\\n(' + percentages[i].toFixed(1) + '%)'
329+
: n + '\\n$' + (values[i]/1000).toFixed(0) + 'K (' + percentages[i].toFixed(1) + '%)'
330+
);
331+
332+
// Update navigation path
333+
window.nav_path.push(clicked_id);
334+
335+
// Update breadcrumb
336+
let breadcrumb_html = '<div style="font-size: 32pt; font-family: Arial, sans-serif; color: #306998; padding: 20px; text-align: center;">';
337+
for (let i = 0; i < window.nav_path.length; i++) {
338+
const node_id = window.nav_path[i];
339+
const node = hierarchy[node_id];
340+
if (i > 0) breadcrumb_html += ' › ';
341+
breadcrumb_html += '<span style="cursor: pointer; font-weight: bold;" onclick="window.navTo(' + i + ')">' + node.name + '</span>';
342+
}
343+
breadcrumb_html += '</div>';
344+
breadcrumb.text = breadcrumb_html;
345+
346+
source.change.emit();
347+
label_source.change.emit();
348+
source.selected.indices = [];
349+
""",
350+
)
351+
352+
# Add tap tool with callback
353+
p.select(type=TapTool).callback = callback
354+
355+
# Create layout
356+
layout = column(breadcrumb, p)
357+
358+
# Save HTML with full interactivity
359+
save(layout, filename="plot.html", resources=INLINE, title="pie-drilldown · bokeh · pyplots.ai")
360+
361+
# Export static PNG (shows initial state)
362+
export_png(p, filename="plot.png", timeout=30)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
library: bokeh
2+
specification_id: pie-drilldown
3+
created: '2025-12-31T14:08:22Z'
4+
updated: '2025-12-31T15:05:37Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20620528667
7+
issue: 3072
8+
python_version: 3.13.11
9+
library_version: 3.8.1
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/pie-drilldown/bokeh/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/pie-drilldown/bokeh/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/pie-drilldown/bokeh/plot.html
13+
quality_score: 72
14+
review:
15+
strengths:
16+
- Good canvas utilization with large 3600x3600 square format - pie chart fills significant
17+
portion of canvas
18+
- Clear hierarchical data structure representing realistic company expense breakdown
19+
(Operations, Marketing, Research, HR)
20+
- Labels are legible with white text on colored slices showing name, value, and
21+
percentage
22+
- Includes breadcrumb navigation text and click indicator for interactive functionality
23+
- Uses colorblind-friendly palette with good contrast between adjacent slices
24+
- Correct title format following spec-id · library · pyplots.ai convention
25+
weaknesses:
26+
- Only two colors visible (green and red) - the yellow from the color palette is
27+
not appearing, limiting color differentiation between categories
28+
- Red and green adjacency is not ideal for colorblind users despite being different
29+
shades
30+
- The Human Resources slice label text is partially difficult to read due to smaller
31+
slice size and label positioning

0 commit comments

Comments
 (0)