Skip to content

Commit 2d2aa07

Browse files
feat(bokeh): implement bar-3d (#3149)
## Implementation: `bar-3d` - bokeh Implements the **bokeh** version of `bar-3d`. **File:** `plots/bar-3d/implementations/bokeh.py` **Parent Issue:** #2857 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20627537973)* --------- 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 649aab5 commit 2d2aa07

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
""" pyplots.ai
2+
bar-3d: 3D Bar Chart
3+
Library: bokeh 3.8.1 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
from bokeh.io import export_png, save
9+
from bokeh.models import ColorBar, Label, LinearColorMapper, Range1d
10+
from bokeh.palettes import Viridis256
11+
from bokeh.plotting import figure
12+
from bokeh.resources import CDN
13+
14+
15+
# Data - Quarterly sales by product category (5 products x 4 quarters)
16+
np.random.seed(42)
17+
18+
products = ["Product A", "Product B", "Product C", "Product D", "Product E"]
19+
quarters = ["Q1", "Q2", "Q3", "Q4"]
20+
21+
n_products = len(products)
22+
n_quarters = len(quarters)
23+
24+
# Sales data (thousands of units) - varied to show different bar heights
25+
sales = np.array(
26+
[
27+
[85, 92, 78, 95], # Product A
28+
[65, 70, 88, 82], # Product B
29+
[120, 115, 130, 125], # Product C (higher performer)
30+
[45, 55, 50, 60], # Product D (lower performer)
31+
[90, 85, 95, 100], # Product E
32+
]
33+
)
34+
35+
# 3D to 2D isometric projection parameters
36+
elev_rad = np.radians(25)
37+
azim_rad = np.radians(45)
38+
39+
# Bar dimensions in 3D space
40+
bar_width = 0.6
41+
bar_depth = 0.6
42+
spacing_x = 1.2 # Space between products
43+
spacing_y = 1.2 # Space between quarters
44+
45+
# Normalize sales for height scaling (0-5 range for good visual proportion)
46+
sales_normalized = sales / sales.max() * 5
47+
48+
# Generate all bar faces using inline projection
49+
all_faces = []
50+
z_min, z_max = 0, sales.max()
51+
52+
for i in range(n_products):
53+
for j in range(n_quarters):
54+
x_center = i * spacing_x
55+
y_center = j * spacing_y
56+
height = sales_normalized[i, j]
57+
original_value = sales[i, j]
58+
59+
hw = bar_width / 2
60+
hd = bar_depth / 2
61+
62+
# Top face corners (3D)
63+
top_corners_3d = [
64+
(x_center - hw, y_center - hd, height),
65+
(x_center + hw, y_center - hd, height),
66+
(x_center + hw, y_center + hd, height),
67+
(x_center - hw, y_center + hd, height),
68+
]
69+
# Project top face to 2D isometric
70+
top_projected = []
71+
for x, y, z in top_corners_3d:
72+
x_rot = x * np.cos(azim_rad) - y * np.sin(azim_rad)
73+
y_rot = x * np.sin(azim_rad) + y * np.cos(azim_rad)
74+
x_proj = x_rot
75+
z_proj = y_rot * np.sin(elev_rad) + z * np.cos(elev_rad)
76+
depth = y_rot * np.cos(elev_rad) - z * np.sin(elev_rad)
77+
top_projected.append((x_proj, z_proj, depth))
78+
top_xs = [p[0] for p in top_projected]
79+
top_ys = [p[1] for p in top_projected]
80+
avg_depth_top = np.mean([p[2] for p in top_projected])
81+
all_faces.append((avg_depth_top, "top", top_xs, top_ys, original_value, height))
82+
83+
# Front face corners (3D)
84+
front_corners_3d = [
85+
(x_center + hw, y_center - hd, 0),
86+
(x_center + hw, y_center + hd, 0),
87+
(x_center + hw, y_center + hd, height),
88+
(x_center + hw, y_center - hd, height),
89+
]
90+
# Project front face to 2D isometric
91+
front_projected = []
92+
for x, y, z in front_corners_3d:
93+
x_rot = x * np.cos(azim_rad) - y * np.sin(azim_rad)
94+
y_rot = x * np.sin(azim_rad) + y * np.cos(azim_rad)
95+
x_proj = x_rot
96+
z_proj = y_rot * np.sin(elev_rad) + z * np.cos(elev_rad)
97+
depth = y_rot * np.cos(elev_rad) - z * np.sin(elev_rad)
98+
front_projected.append((x_proj, z_proj, depth))
99+
front_xs = [p[0] for p in front_projected]
100+
front_ys = [p[1] for p in front_projected]
101+
avg_depth_front = np.mean([p[2] for p in front_projected])
102+
all_faces.append((avg_depth_front, "front", front_xs, front_ys, original_value, height))
103+
104+
# Right side face corners (3D)
105+
right_corners_3d = [
106+
(x_center - hw, y_center + hd, 0),
107+
(x_center + hw, y_center + hd, 0),
108+
(x_center + hw, y_center + hd, height),
109+
(x_center - hw, y_center + hd, height),
110+
]
111+
# Project right face to 2D isometric
112+
right_projected = []
113+
for x, y, z in right_corners_3d:
114+
x_rot = x * np.cos(azim_rad) - y * np.sin(azim_rad)
115+
y_rot = x * np.sin(azim_rad) + y * np.cos(azim_rad)
116+
x_proj = x_rot
117+
z_proj = y_rot * np.sin(elev_rad) + z * np.cos(elev_rad)
118+
depth = y_rot * np.cos(elev_rad) - z * np.sin(elev_rad)
119+
right_projected.append((x_proj, z_proj, depth))
120+
right_xs = [p[0] for p in right_projected]
121+
right_ys = [p[1] for p in right_projected]
122+
avg_depth_right = np.mean([p[2] for p in right_projected])
123+
all_faces.append((avg_depth_right, "right", right_xs, right_ys, original_value, height))
124+
125+
# Sort by depth (back to front - painter's algorithm)
126+
all_faces.sort(key=lambda f: f[0], reverse=True)
127+
128+
# Color mapping using Viridis colormap based on original sales values
129+
color_mapper = LinearColorMapper(palette=Viridis256, low=z_min, high=z_max)
130+
131+
# Create Bokeh figure
132+
p = figure(
133+
width=4800,
134+
height=2700,
135+
title="bar-3d · bokeh · pyplots.ai",
136+
toolbar_location="right",
137+
tools="pan,wheel_zoom,box_zoom,reset,save",
138+
)
139+
140+
# Draw bar faces with shading and semi-transparency to reveal hidden bars
141+
for _depth, face_type, xs, ys, value, _h in all_faces:
142+
# Get base color from Viridis colormap
143+
idx = int((value - z_min) / (z_max - z_min) * 255)
144+
idx = max(0, min(255, idx))
145+
base_color = Viridis256[idx]
146+
147+
# Convert hex to RGB for shading
148+
r = int(base_color[1:3], 16)
149+
g = int(base_color[3:5], 16)
150+
b = int(base_color[5:7], 16)
151+
152+
# Apply shading: top is brightest, front slightly darker, right side darkest
153+
if face_type == "top":
154+
factor = 1.0
155+
elif face_type == "front":
156+
factor = 0.85
157+
else: # right
158+
factor = 0.7
159+
160+
r = int(min(255, r * factor))
161+
g = int(min(255, g * factor))
162+
b = int(min(255, b * factor))
163+
164+
color = f"#{r:02x}{g:02x}{b:02x}"
165+
166+
# Semi-transparent bars (alpha=0.75) to reveal hidden bars behind taller ones
167+
p.patch(x=xs, y=ys, fill_color=color, line_color="#306998", line_width=1.5, line_alpha=0.6, alpha=0.75)
168+
169+
# Calculate plot range from all face coordinates
170+
all_xs = [x for f in all_faces for x in f[2]]
171+
all_ys = [y for f in all_faces for y in f[3]]
172+
173+
x_min, x_max = min(all_xs), max(all_xs)
174+
y_min, y_max = min(all_ys), max(all_ys)
175+
176+
x_pad = (x_max - x_min) * 0.18
177+
y_pad = (y_max - y_min) * 0.15
178+
179+
p.x_range = Range1d(x_min - x_pad * 1.5, x_max + x_pad * 1.5)
180+
p.y_range = Range1d(y_min - y_pad * 1.5, y_max + y_pad)
181+
182+
# Hide default axes for cleaner 3D projection look
183+
p.xaxis.visible = False
184+
p.yaxis.visible = False
185+
186+
# Add 3D axis lines from origin - inline projection
187+
origin_3d = (-0.5, -0.5, 0)
188+
origin_x_rot = origin_3d[0] * np.cos(azim_rad) - origin_3d[1] * np.sin(azim_rad)
189+
origin_y_rot = origin_3d[0] * np.sin(azim_rad) + origin_3d[1] * np.cos(azim_rad)
190+
origin_x = origin_x_rot
191+
origin_y = origin_y_rot * np.sin(elev_rad) + origin_3d[2] * np.cos(elev_rad)
192+
193+
axis_color = "#444444"
194+
axis_width = 4
195+
196+
# X-axis (products direction) - inline projection
197+
x_axis_end_3d = ((n_products - 1) * spacing_x + 0.8, -0.5, 0)
198+
x_axis_x_rot = x_axis_end_3d[0] * np.cos(azim_rad) - x_axis_end_3d[1] * np.sin(azim_rad)
199+
x_axis_y_rot = x_axis_end_3d[0] * np.sin(azim_rad) + x_axis_end_3d[1] * np.cos(azim_rad)
200+
x_axis_end_x = x_axis_x_rot
201+
x_axis_end_y = x_axis_y_rot * np.sin(elev_rad) + x_axis_end_3d[2] * np.cos(elev_rad)
202+
p.line(x=[origin_x, x_axis_end_x], y=[origin_y, x_axis_end_y], line_color=axis_color, line_width=axis_width)
203+
204+
# Y-axis (quarters direction) - inline projection
205+
y_axis_end_3d = (-0.5, (n_quarters - 1) * spacing_y + 0.8, 0)
206+
y_axis_x_rot = y_axis_end_3d[0] * np.cos(azim_rad) - y_axis_end_3d[1] * np.sin(azim_rad)
207+
y_axis_y_rot = y_axis_end_3d[0] * np.sin(azim_rad) + y_axis_end_3d[1] * np.cos(azim_rad)
208+
y_axis_end_x = y_axis_x_rot
209+
y_axis_end_y = y_axis_y_rot * np.sin(elev_rad) + y_axis_end_3d[2] * np.cos(elev_rad)
210+
p.line(x=[origin_x, y_axis_end_x], y=[origin_y, y_axis_end_y], line_color=axis_color, line_width=axis_width)
211+
212+
# Z-axis (height direction) - inline projection
213+
z_axis_end_3d = (-0.5, -0.5, 5.8)
214+
z_axis_x_rot = z_axis_end_3d[0] * np.cos(azim_rad) - z_axis_end_3d[1] * np.sin(azim_rad)
215+
z_axis_y_rot = z_axis_end_3d[0] * np.sin(azim_rad) + z_axis_end_3d[1] * np.cos(azim_rad)
216+
z_axis_end_x = z_axis_x_rot
217+
z_axis_end_y = z_axis_y_rot * np.sin(elev_rad) + z_axis_end_3d[2] * np.cos(elev_rad)
218+
p.line(x=[origin_x, z_axis_end_x], y=[origin_y, z_axis_end_y], line_color=axis_color, line_width=axis_width)
219+
220+
# Add product labels along X-axis
221+
for i, product in enumerate(products):
222+
label_pos_3d = (i * spacing_x, -1.0, 0)
223+
lx_rot = label_pos_3d[0] * np.cos(azim_rad) - label_pos_3d[1] * np.sin(azim_rad)
224+
ly_rot = label_pos_3d[0] * np.sin(azim_rad) + label_pos_3d[1] * np.cos(azim_rad)
225+
label_x = lx_rot
226+
label_y = ly_rot * np.sin(elev_rad) + label_pos_3d[2] * np.cos(elev_rad)
227+
label = Label(
228+
x=label_x, y=label_y - 0.3, text=product, text_font_size="26pt", text_color="#333333", text_align="center"
229+
)
230+
p.add_layout(label)
231+
232+
# Add quarter labels along Y-axis
233+
for j, quarter in enumerate(quarters):
234+
label_pos_3d = (-1.0, j * spacing_y, 0)
235+
lx_rot = label_pos_3d[0] * np.cos(azim_rad) - label_pos_3d[1] * np.sin(azim_rad)
236+
ly_rot = label_pos_3d[0] * np.sin(azim_rad) + label_pos_3d[1] * np.cos(azim_rad)
237+
label_x = lx_rot
238+
label_y = ly_rot * np.sin(elev_rad) + label_pos_3d[2] * np.cos(elev_rad)
239+
label = Label(
240+
x=label_x - 0.4, y=label_y, text=quarter, text_font_size="26pt", text_color="#333333", text_align="right"
241+
)
242+
p.add_layout(label)
243+
244+
# Add axis titles
245+
products_label = Label(
246+
x=x_axis_end_x + 0.3,
247+
y=x_axis_end_y - 0.4,
248+
text="Products",
249+
text_font_size="32pt",
250+
text_color="#333333",
251+
text_font_style="bold",
252+
)
253+
p.add_layout(products_label)
254+
255+
quarters_label = Label(
256+
x=y_axis_end_x - 1.0,
257+
y=y_axis_end_y - 0.3,
258+
text="Quarters",
259+
text_font_size="32pt",
260+
text_color="#333333",
261+
text_font_style="bold",
262+
)
263+
p.add_layout(quarters_label)
264+
265+
# Add color bar for sales value scale
266+
color_bar = ColorBar(
267+
color_mapper=color_mapper,
268+
width=60,
269+
location=(0, 0),
270+
title="Sales (thousands)",
271+
title_text_font_size="28pt",
272+
major_label_text_font_size="22pt",
273+
title_standoff=20,
274+
margin=40,
275+
padding=20,
276+
)
277+
p.add_layout(color_bar, "right")
278+
279+
# Title styling for large canvas
280+
p.title.text_font_size = "44pt"
281+
p.title.text_font_style = "bold"
282+
283+
# Grid styling - subtle
284+
p.xgrid.grid_line_color = "#dddddd"
285+
p.ygrid.grid_line_color = "#dddddd"
286+
p.xgrid.grid_line_alpha = 0.2
287+
p.ygrid.grid_line_alpha = 0.2
288+
p.xgrid.grid_line_dash = [6, 4]
289+
p.ygrid.grid_line_dash = [6, 4]
290+
291+
# Background styling
292+
p.background_fill_color = "#f9f9f9"
293+
p.border_fill_color = "white"
294+
p.outline_line_color = None
295+
p.min_border_right = 220
296+
297+
# Save PNG
298+
export_png(p, filename="plot.png")
299+
300+
# Save HTML for interactive version
301+
save(p, filename="plot.html", resources=CDN, title="bar-3d · bokeh · pyplots.ai")

plots/bar-3d/metadata/bokeh.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
library: bokeh
2+
specification_id: bar-3d
3+
created: '2025-12-31T21:36:45Z'
4+
updated: '2025-12-31T21:45:07Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20627537973
7+
issue: 2857
8+
python_version: 3.13.11
9+
library_version: 3.8.1
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/bar-3d/bokeh/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/bar-3d/bokeh/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/bar-3d/bokeh/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent 3D isometric projection with proper painter's algorithm for correct
17+
face ordering
18+
- Semi-transparency (alpha=0.75) effectively reveals occluded bars as spec recommends
19+
- Viridis color gradient reinforces z-values for better depth perception per spec
20+
- Face shading (top=100%, front=85%, right=70%) provides convincing 3D appearance
21+
- Clean axis labels positioned correctly in 3D space
22+
- Both PNG and interactive HTML outputs provided
23+
- Realistic business scenario with meaningful variation in data
24+
weaknesses:
25+
- Title format uses hyphens/dashes instead of middle dots as required by SC-06

0 commit comments

Comments
 (0)